@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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// dsh-ling host — 导入记录(每次把历史请进门,都留一笔可查的账)。
|
|
2
|
+
// 设计(2026-09-11,作者拍板「甲」):
|
|
3
|
+
// - **一次导入 = 一条账**:传输层仍按批(200/批)发送,但每批带同一个 runId,
|
|
4
|
+
// host 端按 runId 累加进同一条账(批数/计数相加),日志不暴露 HTTP 细节;
|
|
5
|
+
// - `at` = **导入开始时刻并固定**(首批写入后不再变动,账不会在列表里跳位置);`updatedAt` 记最后一批;
|
|
6
|
+
// - 成功批由 /import/file/batch 自己记账;失败批由客户端经 /import/log 上报(只加 `errors` 批数);
|
|
7
|
+
// - 三条入口共用:文件导入 / 本机 DSH 扫描 / 网页端库扫描;
|
|
8
|
+
// - kv `import.log.<at>` 存 JSON;保留最近 50 条,满了作废最老的。
|
|
9
|
+
|
|
10
|
+
export const IMPORT_LOG_PREFIX = 'import.log.';
|
|
11
|
+
export const IMPORT_LOG_KEEP = 50;
|
|
12
|
+
const AT_PAST_MS = 30 * 24 * 3600 * 1000; // 开始时刻最多回溯 30 天
|
|
13
|
+
const AT_FUTURE_MS = 60 * 1000; // 允许 1 分钟时钟偏差
|
|
14
|
+
const NUM_KEYS = ['accepted', 'newRows', 'refreshed', 'upgraded', 'folded', 'removedImport', 'degraded', 'rejected', 'seen', 'errors'];
|
|
15
|
+
|
|
16
|
+
function pad(n) { return n < 10 ? '0' + n : String(n); }
|
|
17
|
+
|
|
18
|
+
/** 开始时刻归一:非法/越界一律退回 now —— 账的时间必须可信。 */
|
|
19
|
+
export function cleanAt(value, now = Date.now()) {
|
|
20
|
+
const n = Number(value);
|
|
21
|
+
if (!Number.isFinite(n) || n <= 0) return now;
|
|
22
|
+
if (n > now + AT_FUTURE_MS) return now;
|
|
23
|
+
if (n < now - AT_PAST_MS) return now;
|
|
24
|
+
return Math.floor(n);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function numberFields(entry) {
|
|
28
|
+
const out = {};
|
|
29
|
+
for (const k of NUM_KEYS) out[k] = Number(entry?.[k]) || 0;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 同一次导入(runId)找已有账:账 ≤50 条,扫一遍的代价可忽略;坏值跳过。 */
|
|
34
|
+
function findByRun(memory, runId) {
|
|
35
|
+
for (const r of memory.kvList(IMPORT_LOG_PREFIX)) {
|
|
36
|
+
try {
|
|
37
|
+
const v = JSON.parse(String(r.value));
|
|
38
|
+
if (v && typeof v === 'object' && v.runId === runId) return { key: String(r.key), value: v };
|
|
39
|
+
} catch { /* 坏值跳过 */ }
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function prune(memory) {
|
|
45
|
+
const keys = memory.kvList(IMPORT_LOG_PREFIX).map((r) => String(r.key)).sort();
|
|
46
|
+
while (keys.length > IMPORT_LOG_KEEP) memory.kvDel(keys.shift());
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 键 = 开始时刻(同毫秒重复导入时加补零序号兜底),保证列表排序≈时间序。 */
|
|
50
|
+
function keyFor(memory, at) {
|
|
51
|
+
let key = IMPORT_LOG_PREFIX + at;
|
|
52
|
+
let n = 1;
|
|
53
|
+
while (memory.kvGet(key) !== undefined) key = IMPORT_LOG_PREFIX + at + '.' + String(++n).padStart(3, '0');
|
|
54
|
+
return key;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 记账。带 runId 时**累加**进同一条账(一次导入一条);不带则为独立一笔(单次扫描)。
|
|
59
|
+
* 返回落的 kv key。
|
|
60
|
+
*/
|
|
61
|
+
export function logImport(memory, entry) {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
const runId = String(entry?.runId || '').slice(0, 64);
|
|
64
|
+
const at = cleanAt(entry?.at, now);
|
|
65
|
+
const inc = numberFields(entry);
|
|
66
|
+
const batches = Math.max(1, Math.min(Number(entry?.batches) || 1, 5000));
|
|
67
|
+
const name = String(entry?.name || '').slice(0, 120);
|
|
68
|
+
const note = String(entry?.note || '').slice(0, 200);
|
|
69
|
+
const kind = String(entry?.kind || 'file');
|
|
70
|
+
|
|
71
|
+
if (runId) {
|
|
72
|
+
const found = findByRun(memory, runId);
|
|
73
|
+
if (found) {
|
|
74
|
+
const merged = { ...found.value, updatedAt: now };
|
|
75
|
+
merged.batches = (Number(found.value.batches) || 1) + batches;
|
|
76
|
+
for (const k of NUM_KEYS) merged[k] = (Number(found.value[k]) || 0) + inc[k];
|
|
77
|
+
if (!merged.name && name) merged.name = name; // 名字取首个非空
|
|
78
|
+
if (note) merged.note = note; // 备注取最新
|
|
79
|
+
// at / startedAt / runId / kind 保持首值
|
|
80
|
+
memory.kvSet(found.key, JSON.stringify(merged));
|
|
81
|
+
prune(memory);
|
|
82
|
+
return found.key;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const key = keyFor(memory, at);
|
|
87
|
+
memory.kvSet(key, JSON.stringify({
|
|
88
|
+
at, startedAt: at, updatedAt: now, runId: runId || null,
|
|
89
|
+
kind, name, batches, ...inc, note,
|
|
90
|
+
}));
|
|
91
|
+
prune(memory);
|
|
92
|
+
return key;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** 读取最近 limit 条(倒序,新→旧)。 */
|
|
96
|
+
export function listImportLog(memory, { limit = 50 } = {}) {
|
|
97
|
+
const rows = memory.kvList(IMPORT_LOG_PREFIX);
|
|
98
|
+
const items = [];
|
|
99
|
+
for (const r of rows) {
|
|
100
|
+
try {
|
|
101
|
+
const v = JSON.parse(String(r.value));
|
|
102
|
+
if (v && typeof v === 'object' && v.at) items.push(v);
|
|
103
|
+
} catch { /* 坏值跳过 */ }
|
|
104
|
+
}
|
|
105
|
+
items.sort((a, b) => Number(b.at) - Number(a.at));
|
|
106
|
+
return items.slice(0, Math.max(1, Math.min(limit, IMPORT_LOG_KEEP)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 展示用的一行摘要(纯函数,便于单测)。 */
|
|
110
|
+
export function formatLogLine(item) {
|
|
111
|
+
const d = new Date(Number(item.at));
|
|
112
|
+
const when = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
113
|
+
if (item.kind === 'dsh') {
|
|
114
|
+
return `${when} · 本机 DSH 扫描 · 候选 ${item.seen || 0} · 新增 ${item.newRows}`;
|
|
115
|
+
}
|
|
116
|
+
if (item.kind === 'dsweb') {
|
|
117
|
+
return `${when} · 网页端库扫描 · 源库 ${item.seen || 0} 条 · 新增 ${item.newRows} · 刷新 ${item.refreshed}`;
|
|
118
|
+
}
|
|
119
|
+
const parts = [`新增 ${item.newRows}`, `刷新 ${item.refreshed}`];
|
|
120
|
+
if (item.upgraded) parts.push(`补全原文 ${item.upgraded}`);
|
|
121
|
+
if (item.folded) parts.push(`并入网页端 ${item.folded}`);
|
|
122
|
+
if (item.removedImport) parts.push(`清理副本 ${item.removedImport}`);
|
|
123
|
+
if (item.degraded) parts.push(`降级轻量 ${item.degraded}`);
|
|
124
|
+
if (item.rejected) parts.push(`拒绝 ${item.rejected}`);
|
|
125
|
+
const batches = Number(item.batches) || 0;
|
|
126
|
+
if (batches > 1) parts.push(`${batches} 批`);
|
|
127
|
+
if (item.errors) parts.push(`失败 ${item.errors} 批`);
|
|
128
|
+
return `${when} · 文件导入${item.name ? '(' + item.name + ')' : ''} · ${parts.join(' · ')}`;
|
|
129
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// dsh-ling host — shared keyword helpers (used by inject & lifecycle).
|
|
2
|
+
// v0 query tokenizer: overlapping 4-grams per CJK segment. Stored overview
|
|
3
|
+
// keywords are 4..6-char windows; substring containment in l1.kwOverlap then
|
|
4
|
+
// hits regardless of window offsets (e.g. stored "双份项目讨论" ⊃ query "项目讨论").
|
|
5
|
+
export function keywordsFrom(text) {
|
|
6
|
+
if (!text) return [];
|
|
7
|
+
const segs = String(text).split(/[^\u4e00-\u9fff]+/).filter((s) => s.length >= 2);
|
|
8
|
+
const out = [];
|
|
9
|
+
for (const seg of segs.slice(0, 6)) {
|
|
10
|
+
if (seg.length <= 6) {
|
|
11
|
+
if (!out.includes(seg)) out.push(seg);
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
// 滑动 4-gram
|
|
15
|
+
for (let i = 0; i + 4 <= seg.length && out.length < 24; i += 2) {
|
|
16
|
+
const g = seg.slice(i, i + 4);
|
|
17
|
+
if (!out.includes(g)) out.push(g);
|
|
18
|
+
}
|
|
19
|
+
if (out.length < 24) {
|
|
20
|
+
const head = seg.slice(0, 6);
|
|
21
|
+
if (!out.includes(head)) out.push(head);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return out.slice(0, 16);
|
|
25
|
+
}
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// dsh-ling host — system-prompt injection (DESIGN §4.2/4.3, REPORT-02).
|
|
2
|
+
// One global section; text() is evaluated at every model-step assembly and is
|
|
3
|
+
// a PURE READ of the session's frozen snapshot:
|
|
4
|
+
// - running sessions: always return the frozen snapshot (D4 — never mutate,
|
|
5
|
+
// never rebuild mid-run);
|
|
6
|
+
// - idle sessions with a stale snapshot: rebuild lazily (one-time invalidation);
|
|
7
|
+
// - subagent/child/unknown sessions: return '' (R5).
|
|
8
|
+
import { svc, pick, neutralizeMustache, sessionKind } from './util.js';
|
|
9
|
+
import { buildSnapshotText, firstUserText } from './snapshot.js';
|
|
10
|
+
import { currentMode } from './mode.js';
|
|
11
|
+
import { keywordsFrom } from './inject-common.js';
|
|
12
|
+
import { timeAnchorDate, timeAnchorLive } from './clock.js';
|
|
13
|
+
|
|
14
|
+
export const SECTION_NAME = 'dsh-ling.memory';
|
|
15
|
+
export const SECTION_ORDER = 1500; // persona(order 0)之后、指令之前
|
|
16
|
+
/** 时间锚 · context 侧(2026-09-23 搬迁):时段 + 间隔挂在**末条消息**上。
|
|
17
|
+
* 平台(context)只在本轮文本真的不同时才产出新快照,而 section 改一个字就作废整个前缀。
|
|
18
|
+
* order 取 300:平台保留位只占 110/115/120,留出可读间隔。 */
|
|
19
|
+
export const CLOCK_CONTEXT_NAME = 'dsh-ling.clock';
|
|
20
|
+
export const CLOCK_CONTEXT_ORDER = 300;
|
|
21
|
+
|
|
22
|
+
function agentOf(assembly) {
|
|
23
|
+
return pick(() => assembly?.agent, () => assembly?.scope?.agent, () => assembly?.ctx?.agent);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sessionIdOfAgent(agent) {
|
|
27
|
+
return pick(
|
|
28
|
+
() => agent?.session?.id,
|
|
29
|
+
() => agent?.session?.header?.id,
|
|
30
|
+
() => agent?.sessionId,
|
|
31
|
+
() => agent?.id,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 会话种类(D9-a):'top' | 'fork' | 'subagent' | 'unknown'。
|
|
36
|
+
* 旧版把 fork 与 subagent 一并当 child 丢弃 ⇒ 分叉会话记忆真空;
|
|
37
|
+
* 现在**分叉会话照常注入**(读血缘链),只有子代理与"取不到 header"仍保守拒绝。 */
|
|
38
|
+
function kindOfAgent(agent) {
|
|
39
|
+
const header = pick(
|
|
40
|
+
() => agent?.session?.header,
|
|
41
|
+
() => agent?.header,
|
|
42
|
+
);
|
|
43
|
+
return sessionKind(header);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 能否注入(D9-a):顶层与分叉会话可以,子代理/未知不可以。 */
|
|
47
|
+
function canInject(kind) {
|
|
48
|
+
return kind === 'top' || kind === 'fork';
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** B#9(2026-09-22 审计):消费注入感知水位 —— **只在真正要把文本送进模型时调用**。
|
|
52
|
+
* 渲染(buildSnapshotText)本身不推进:它也会被"只重建、未开口"的路径调用
|
|
53
|
+
* (invalidateSession / refreshIdleSnapshot),在那里推进会把主人刚改的东西
|
|
54
|
+
* 标记成已读,等真正开口时反而看不到。水位按会话、单调不回退。 */
|
|
55
|
+
function advanceLogWatermark(memory, sessionId, maxId) {
|
|
56
|
+
const v = Number(maxId);
|
|
57
|
+
if (!sessionId || !Number.isFinite(v) || v <= 0) return;
|
|
58
|
+
try {
|
|
59
|
+
const k = 'branch_log_wm.' + String(sessionId);
|
|
60
|
+
if (v > Number(memory.kvGet(k) || 0)) memory.kvSet(k, String(v));
|
|
61
|
+
// 复盘守门(2026-09-22 用户:「动记忆树不能是随意的,应该是慎重严肃的」):
|
|
62
|
+
// 另记一个**全局最后同步**水位,回答"自器灵上次开口以来累积了多少笔改动还没交代"
|
|
63
|
+
// (memory.branchLogPending 读它)。与会话水位互补 —— 会话水位管"这个会话看到什么",
|
|
64
|
+
// 本键管"是否还没跟器灵交代过"。
|
|
65
|
+
if (v > Number(memory.kvGet('branch_log_wm.last') || 0)) memory.kvSet('branch_log_wm.last', String(v));
|
|
66
|
+
} catch { /* 推进失败仅导致下次重复提示,不影响注入 */ }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function registerMemorySection(ctx, gate, memory, settings) {
|
|
70
|
+
const sp = svc(ctx, 'systemPrompt');
|
|
71
|
+
if (!sp || typeof sp.section !== 'function') {
|
|
72
|
+
console.debug('[dsh-ling] systemPrompt service unavailable; injection disabled');
|
|
73
|
+
memory.kvSet('inject.section_registered', '0:no-service');
|
|
74
|
+
return () => {};
|
|
75
|
+
}
|
|
76
|
+
let registered = false;
|
|
77
|
+
try {
|
|
78
|
+
const ret = sp.section({
|
|
79
|
+
name: SECTION_NAME,
|
|
80
|
+
order: SECTION_ORDER,
|
|
81
|
+
complete: false,
|
|
82
|
+
text: (assembly) => renderMemorySection(assembly, gate, memory, settings),
|
|
83
|
+
});
|
|
84
|
+
registered = true;
|
|
85
|
+
memory.kvSet('inject.section_registered', '1');
|
|
86
|
+
void ret;
|
|
87
|
+
} catch (e) {
|
|
88
|
+
console.debug('[dsh-ling] systemPrompt.section failed', e);
|
|
89
|
+
memory.kvSet('inject.section_registered', '0:' + String(e?.message ?? e).slice(0, 120));
|
|
90
|
+
}
|
|
91
|
+
// ── 时间锚 · context 侧(2026-09-23) ────────────────────────────────
|
|
92
|
+
// 时段与间隔挂末条消息,不再进 system 前缀。旧写法每请求一次前缀全废
|
|
93
|
+
// (实测回合内断裂 174 次 = 未缓存 token 43.96%);档位化后一天最多变几次。
|
|
94
|
+
let clockRegistered = false;
|
|
95
|
+
try {
|
|
96
|
+
const ret2 = sp.context({
|
|
97
|
+
name: CLOCK_CONTEXT_NAME,
|
|
98
|
+
order: CLOCK_CONTEXT_ORDER,
|
|
99
|
+
// ⚠️ C-03(1.5.1 红蓝对抗,2026-09-25):时间锚搬到 context 侧时**必须同样中和**。
|
|
100
|
+
// 锚里的称呼来自 `persona.userTitle` —— 那是**用户可编辑的自由文本**,而且
|
|
101
|
+
// `corpus-suggest.js` 会从真实对话里挖出候选、经建议通道采纳写进 persona。
|
|
102
|
+
// 一旦它含 `{{x}}`,宿主 renderPrompt 的 interpolate 会**直接抛异常**,
|
|
103
|
+
// 而 agent-loop 调用处无 try/catch ⇒ **该会话每一步模型调用都失败**。
|
|
104
|
+
// 这正是 1.3.1 拆掉的那颗雷(S12)的同类面:section 侧一直有中和
|
|
105
|
+
// (见 renderMemorySection 的 `neutralizeMustache(anchor)`),搬迁时漏了这一处。
|
|
106
|
+
text: () => neutralizeMustache(timeAnchorLive(memory, settings)),
|
|
107
|
+
});
|
|
108
|
+
clockRegistered = true;
|
|
109
|
+
memory.kvSet('inject.clock_ctx_registered', '1');
|
|
110
|
+
void ret2;
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.debug('[dsh-ling] systemPrompt.context failed', e);
|
|
113
|
+
memory.kvSet('inject.clock_ctx_registered', '0:' + String(e?.message ?? e).slice(0, 120));
|
|
114
|
+
}
|
|
115
|
+
return () => {
|
|
116
|
+
if (registered) {
|
|
117
|
+
try {
|
|
118
|
+
sp.section?.({ name: SECTION_NAME, order: SECTION_ORDER, complete: false, text: '' });
|
|
119
|
+
} catch {}
|
|
120
|
+
registered = false;
|
|
121
|
+
}
|
|
122
|
+
if (clockRegistered) {
|
|
123
|
+
try {
|
|
124
|
+
// 同名重注册即覆盖:与 section 的注销形态一致
|
|
125
|
+
sp.context?.({ name: CLOCK_CONTEXT_NAME, order: CLOCK_CONTEXT_ORDER, text: '' });
|
|
126
|
+
} catch {}
|
|
127
|
+
clockRegistered = false;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** 注入面文本渲染(导出供回归断言直接调用,2026-09-23)。
|
|
133
|
+
* 导出是刻意的:`{{…}}` 与时间锚两处病灶都靠"跨分钟取样"的断言钉住,
|
|
134
|
+
* 没有导出就只能靠端到端间接验证。 */
|
|
135
|
+
export function renderMemorySection(assembly, gate, memory, settings) {
|
|
136
|
+
try {
|
|
137
|
+
if (!assembly) return '';
|
|
138
|
+
const agent = agentOf(assembly);
|
|
139
|
+
const sessionId = agent ? sessionIdOfAgent(agent) : undefined;
|
|
140
|
+
if (!sessionId) return '';
|
|
141
|
+
if (!canInject(kindOfAgent(agent))) return ''; // 子代理/未知不注入(R5);D9-a:分叉会话注入血缘链
|
|
142
|
+
// 时间锚 · system 侧(2026-09-23 搬迁):**只有日期 + 星期**(-+纪念日)。
|
|
143
|
+
// 时分/时段/间隔已移出 —— 它们分钟级抖动,会让整段会话前缀每请求作废一次。
|
|
144
|
+
// 时段与间隔现在走 systemPrompt.context()(见 registerMemorySection 的 clock 段)。
|
|
145
|
+
const anchor = timeAnchorDate(memory, settings);
|
|
146
|
+
// 注入面出口 ②(最终侧,2026-09-18):时间锚不经快照,未来新增的 part 也只走这里,
|
|
147
|
+
// 故在最外层再中和一次(幂等)。①+② 合计覆盖全部注入文本。
|
|
148
|
+
const withAnchor = (text) => {
|
|
149
|
+
const body = neutralizeMustache(text || '');
|
|
150
|
+
if (!anchor) return body;
|
|
151
|
+
return (body ? body + '\n\n' : '') + neutralizeMustache(anchor);
|
|
152
|
+
};
|
|
153
|
+
const snap = gate.snapshotOf(sessionId);
|
|
154
|
+
if (!snap) {
|
|
155
|
+
// 首轮组装可能先于/晚于 session-start 事件:空闲时补建,运行时不建(D4)
|
|
156
|
+
if (gate.isRunning(sessionId)) return withAnchor('');
|
|
157
|
+
const mode = currentMode(memory, settings, sessionId);
|
|
158
|
+
const kw = keywordsFrom(firstUserText(agent));
|
|
159
|
+
const built = buildSnapshotText(null, memory, settings, sessionId, { keywords: kw, bumpHits: true });
|
|
160
|
+
gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
|
|
161
|
+
advanceLogWatermark(memory, sessionId, built.logMaxId); // B#9:定稿即消费
|
|
162
|
+
return withAnchor(built.text);
|
|
163
|
+
}
|
|
164
|
+
// 冻结:running 一律返回既有快照(即使 stale);时间锚仍实时
|
|
165
|
+
if (gate.isRunning(sessionId)) {
|
|
166
|
+
advanceLogWatermark(memory, sessionId, snap.logMaxId); // B#9:冻结快照投递即消费
|
|
167
|
+
return withAnchor(snap.text || '');
|
|
168
|
+
}
|
|
169
|
+
// idle + stale → 懒重建(一次性失效);重建必须带上首次定稿时的关键词,否则排序口径会变
|
|
170
|
+
if (snap.stale) {
|
|
171
|
+
const mode = currentMode(memory, settings, sessionId);
|
|
172
|
+
const kw = Array.isArray(snap.keywords) ? snap.keywords : keywordsFrom(firstUserText(agent));
|
|
173
|
+
const built = buildSnapshotText(null, memory, settings, sessionId, { keywords: kw });
|
|
174
|
+
gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
|
|
175
|
+
advanceLogWatermark(memory, sessionId, built.logMaxId); // B#9:重建后投递即消费
|
|
176
|
+
return withAnchor(built.text);
|
|
177
|
+
}
|
|
178
|
+
advanceLogWatermark(memory, sessionId, snap.logMaxId); // B#9:命中缓存快照投递即消费
|
|
179
|
+
return withAnchor(snap.text || '');
|
|
180
|
+
} catch (e) {
|
|
181
|
+
console.debug('[dsh-ling] textForAssembly failed', e);
|
|
182
|
+
return '';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 供 lifecycle:agent/session-start 时为新顶层会话定稿快照。 */
|
|
187
|
+
export function freezeSnapshotForSession(gate, memory, settings, agent) {
|
|
188
|
+
const sessionId = agent ? sessionIdOfAgent(agent) : undefined;
|
|
189
|
+
if (!sessionId) return null;
|
|
190
|
+
const kind = kindOfAgent(agent);
|
|
191
|
+
if (!canInject(kind)) return null;
|
|
192
|
+
const mode = currentMode(memory, settings, sessionId);
|
|
193
|
+
// 关键词上下文必须随快照存下来:重建时若丢失,L1 的排序口径会悄悄换一套
|
|
194
|
+
// (关键词加成最高 +1.8,远大于热度项 0.3~0.5)
|
|
195
|
+
const kw = keywordsFrom(firstUserText(agent));
|
|
196
|
+
const built = buildSnapshotText(null, memory, settings, sessionId, { keywords: kw, bumpHits: true });
|
|
197
|
+
gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
|
|
198
|
+
return { sessionId, mode, chars: built.text.length };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 人格/模式变更后的失效入口:运行中仅标记 stale(注入面不变),空闲立即重建。 */
|
|
202
|
+
export function invalidateSession(gate, memory, settings, sessionId) {
|
|
203
|
+
if (!sessionId) return { invalidated: false };
|
|
204
|
+
const snap = gate.snapshotOf(sessionId);
|
|
205
|
+
if (gate.isRunning(sessionId)) {
|
|
206
|
+
gate.markSnapStale(sessionId);
|
|
207
|
+
return { invalidated: false, queued: true, running: true };
|
|
208
|
+
}
|
|
209
|
+
const mode = currentMode(memory, settings, sessionId);
|
|
210
|
+
const kw = Array.isArray(snap?.keywords) ? snap.keywords : [];
|
|
211
|
+
const built = buildSnapshotText(null, memory, settings, sessionId, { keywords: kw });
|
|
212
|
+
gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
|
|
213
|
+
return { invalidated: true, mode };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------- B:空闲边界刷新(长会话在 idle 时把 L1 追平到最新) ----------------
|
|
217
|
+
const REFRESH_KEY = 'snap.refresh:';
|
|
218
|
+
|
|
219
|
+
function readRefreshRecord(memory, sessionId) {
|
|
220
|
+
try {
|
|
221
|
+
const raw = memory.kvGet(REFRESH_KEY + sessionId);
|
|
222
|
+
if (!raw) return { count: 0, lastAt: 0 };
|
|
223
|
+
const v = JSON.parse(String(raw));
|
|
224
|
+
return { count: Number(v?.count) || 0, lastAt: Number(v?.lastAt) || 0 };
|
|
225
|
+
} catch {
|
|
226
|
+
return { count: 0, lastAt: 0 };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 面板/状态接口用:该会话在空闲边界刷新过几次、最近一次何时。 */
|
|
231
|
+
export function refreshStats(memory, sessionId) {
|
|
232
|
+
return sessionId ? readRefreshRecord(memory, sessionId) : { count: 0, lastAt: 0 };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* 空闲边界刷新:仅当「快照存在 + 会话空闲 + memory_version 变了 + 距上次刷新 ≥ 最小间隔」时重建。
|
|
237
|
+
* 只会在 running→idle 时被调用,因此 D4 不变量(运行中注入面逐字节不变)天然保持。
|
|
238
|
+
*
|
|
239
|
+
* @param {object} opts
|
|
240
|
+
* - now:注入时间(测试用)
|
|
241
|
+
* - summarize:可选,刷新前先跑一次"本会话增量概述"(纯文本、零 LLM),否则追平的是上一轮概述
|
|
242
|
+
* - minIntervalMin:覆盖最小间隔(测试用)
|
|
243
|
+
* @returns {{refreshed:boolean, reason:string, count?:number, chars?:number, lastAt?:number}}
|
|
244
|
+
*/
|
|
245
|
+
export function refreshIdleSnapshot(gate, memory, settings, sessionId, { now = Date.now(), summarize = null, minIntervalMin = null } = {}) {
|
|
246
|
+
try {
|
|
247
|
+
if (!sessionId) return { refreshed: false, reason: 'no-session' };
|
|
248
|
+
const s = settings && typeof settings.get === 'function' ? settings.get() : (settings || {});
|
|
249
|
+
const cfg = s.memory || {};
|
|
250
|
+
if (cfg.idleRefresh === false) return { refreshed: false, reason: 'disabled' };
|
|
251
|
+
if (gate.isRunning(sessionId)) return { refreshed: false, reason: 'running' };
|
|
252
|
+
const snap = gate.snapshotOf(sessionId);
|
|
253
|
+
if (!snap || !snap.text) return { refreshed: false, reason: 'no-snapshot' };
|
|
254
|
+
// ① 先让概述器落库(它会 bump memory_version),否则下面比对的是上一轮版本
|
|
255
|
+
if (typeof summarize === 'function') {
|
|
256
|
+
try {
|
|
257
|
+
summarize();
|
|
258
|
+
} catch (e) {
|
|
259
|
+
console.debug('[dsh-ling] idle summarize failed', e);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const cur = String(memory.kvGet('memory_version') || '0');
|
|
263
|
+
if (String(snap.memoryVersion || '0') === cur) return { refreshed: false, reason: 'unchanged' };
|
|
264
|
+
const minMs = Math.max(0, Number(minIntervalMin ?? cfg.idleRefreshMinIntervalMin ?? 10)) * 60000;
|
|
265
|
+
const rec = readRefreshRecord(memory, sessionId);
|
|
266
|
+
if (minMs > 0 && rec.lastAt && now - rec.lastAt < minMs) {
|
|
267
|
+
return { refreshed: false, reason: 'throttled', count: rec.count, lastAt: rec.lastAt };
|
|
268
|
+
}
|
|
269
|
+
const mode = currentMode(memory, settings, sessionId);
|
|
270
|
+
const kw = Array.isArray(snap.keywords) ? snap.keywords : [];
|
|
271
|
+
const built = buildSnapshotText(null, memory, settings, sessionId, { keywords: kw, now });
|
|
272
|
+
// ② 内容判等:文本没变 → 不计数(避免无谓的前缀失效),但仍刷新快照元信息
|
|
273
|
+
if (built.text === snap.text) {
|
|
274
|
+
gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
|
|
275
|
+
return { refreshed: false, reason: 'same-text', count: rec.count };
|
|
276
|
+
}
|
|
277
|
+
gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
|
|
278
|
+
const next = { count: rec.count + 1, lastAt: now };
|
|
279
|
+
memory.kvSet(REFRESH_KEY + sessionId, JSON.stringify(next));
|
|
280
|
+
return { refreshed: true, reason: 'rebuilt', count: next.count, chars: built.text.length };
|
|
281
|
+
} catch (e) {
|
|
282
|
+
console.debug('[dsh-ling] refreshIdleSnapshot failed', e);
|
|
283
|
+
return { refreshed: false, reason: 'error' };
|
|
284
|
+
}
|
|
285
|
+
}
|