@nightpainters/dsh-ling 1.5.1 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/host/api.js CHANGED
@@ -5,6 +5,7 @@ import { svc, pick, isoDate } from './util.js';
5
5
  import { assemblePersona, pronounOf, pronounize } from './persona.js';
6
6
  import { applyModeToSession, currentMode, currentModelSelection, MODE_LABEL, resolveModeConfig } from './mode.js';
7
7
  import { invalidateSession, refreshStats } from './inject.js';
8
+ import { sessionLineProbe, sessionLineState } from './session-line.js'; // E3(2026-09-27):sessionLineState 回到面板档的 `sessionLine` 字段;轮询档仍一个字节都不带
8
9
  import { selectL1 } from './l1.js';
9
10
  import { applySuggestionAsRule, dismissSuggestion } from './feedback.js';
10
11
  import { applyCorpusSuggestion, dismissCorpusSuggestion } from './suggestions.js';
@@ -39,6 +40,7 @@ import {
39
40
  } from './dsweb-summary.js';
40
41
  import { TRUNK_ID } from './memory.js';
41
42
  import { bucketize, nameBucket } from './autotree.js';
43
+ import { accessLogStatus } from '../audit/index.js';
42
44
 
43
45
  // (旧)只校验 cookie 名形状的守卫已废弃 —— 见 guard.js:来源栅栏 + 名字对撞(2026-09-16 加固 ①+②)
44
46
  // 网页端聊天记录库的默认路径:私有路径不写死在代码里 —— 由 UI 传入,或用 DSH_LING_DSWEB_DB 环境变量覆盖。
@@ -317,6 +319,8 @@ export function registerApi(ctx, deps, config) {
317
319
  hasSeal: !!s?.persona?.sealPhrase,
318
320
  sealPhrase: s?.persona?.sealPhrase ?? '', // 明文回显用(锁只造庄重,不保密)
319
321
  extraLore: s?.persona?.extraLore ?? '',
322
+ // 职责(1.5.2 I-5):白名单投影必须带出,否则人格中心恒空(与 habits 那次同族的"漏字段=界面静默错")
323
+ duty: s?.persona?.duty ?? '',
320
324
  pronoun: s?.persona?.pronoun || '她', // 第三人称代词(用户设定;预设 她/他/TA/它,可自定义)
321
325
  stylesWork: s?.styles?.work ?? '',
322
326
  stylesLife: s?.styles?.life ?? '',
@@ -395,13 +399,19 @@ export function registerApi(ctx, deps, config) {
395
399
  return tmp;
396
400
  };
397
401
 
402
+ // ---- /state 分档(1.5.2,设计 §3) ------------------------------------------------------
403
+ // 面板路径(client.js:890)恒带 `l0Preview=1`,轮询路径(client.js:604-608)从不带
404
+ // ⇒ 用**已有**的这个开关当"面板档"标记:**不新增查询参数、前端零改动**。
405
+ // 轮询档只回 `applyStateToStore`(client.js:610-620)真会读到的字段 —— 重字段在轮询应答里今天就没被读过,
406
+ // 省掉它们不改变任何前端行为(而"按需省字段"是危险的,见设计 §4:前端每拍**无条件**覆写 store)。
407
+ // ⚠️ `persona` 必须留在轮询档:client.js:612 每拍 `setPronounFrom(r.persona)`,省掉第三人称代词会卡住。
408
+ // ⚠️ `audit` 必须留在轮询档:client.js:618 的 `!== undefined` 守卫 + 侧栏红点,唯一"不主动查也看得见"的日志故障位。
398
409
  const stateForGlobal = (q) => {
399
410
  const base = settings.get();
400
411
  const tmp = previewOverrides(q) || base;
401
412
  const mode = base.mode?.lastMode === 'work' ? 'work' : 'life';
402
- const all = memory.listOverviews({ onlyOk: true });
403
- const wantPreview = q && q.get('l0Preview') === '1';
404
- return {
413
+ const wantPreview = q && q.get('l0Preview') === '1'; // 面板档标记
414
+ const light = {
405
415
  ok: true,
406
416
  plugin: 'dsh-ling',
407
417
  sessionId: null,
@@ -410,26 +420,79 @@ export function registerApi(ctx, deps, config) {
410
420
  modeLabel: MODE_LABEL[mode],
411
421
  running: false,
412
422
  pending: 0,
413
- snapshot: null,
414
- l0PreviewText: wantPreview ? l0WithClock(tmp, mode) : undefined,
415
423
  persona: personaInfo(base),
416
- modeMapping: base.mode?.mapping ?? {},
417
424
  lastMode: mode,
425
+ audit: auditHealth(), // C-07:全局(无活动会话)视图也要看得到日志健康面
426
+ };
427
+ if (!wantPreview) return light; // 轮询档:到此为止(下面这些只有面板路径才付)
428
+ return {
429
+ ...light,
430
+ snapshot: null,
431
+ l0PreviewText: l0WithClock(tmp, mode),
418
432
  memory: {
419
- overviews: all.length,
433
+ overviews: memory.countOverviews({ onlyOk: true }),
420
434
  rawTurnSessions: rawTurnSessionCount(memory),
421
435
  fingerprint: memory.fingerprint(),
422
436
  },
423
- updates: base.updates ?? {},
424
437
  };
425
438
  };
426
439
 
440
+ // ---- E3 面板档只读读数(2026-09-27)------------------------------------------------------
441
+ // 背景:「那一行」在盘上有**两条**,长得像但不是一回事 —— ①L2 `[记忆·开场]` 里本会话那一条
442
+ // (只有标题,概述器 15 分钟一拍)与 ②E0 `agent/pre-step` 追加的活行(`[本次会话·进展 #N] ...`)。
443
+ // 面板要把两条**各自标注**地显示出来,并把"为什么没变"变成看得见的读数 ⇒ 需要下面几个只读字段。
444
+ // 规矩(照 auditHealth):**每个新字段各自 try/catch** —— 任何一个读数出错都不许把 /state 带崩;
445
+ // 且**只在 l0Preview=1 的面板档付这个钱**(轮询档今天 43× 提速的成果不能被这几个字段吃回去)。
446
+ const SNAP_TEXT_CAP = 8000; // 冻结快照原文上限:面板是只读展示(看全文走面板档、纯读、不进 prompt),8k 字符够看清那一轮读到什么
447
+ const e3Snapshot = (snap) => {
448
+ try {
449
+ const t = String((snap && snap.text) || '');
450
+ return t.length > SNAP_TEXT_CAP ? { text: t.slice(0, SNAP_TEXT_CAP), truncated: true } : { text: t };
451
+ } catch { return {}; } // 取不到就**不给**这个字段 ⇒ 前端按"缺字段"处理(绝不用空串冒充"快照是空的")
452
+ };
453
+ // E1 深摘冻结:该会话是否已被深摘(`deepsummary.js:267` 写的就是 `done:<iso>`)—— 冻结则那一行正文不再更新
454
+ const e3DeepDone = (sid) => {
455
+ try { return String(memory.kvGet('deep:' + sid) || '').startsWith('done:'); } catch { return null; }
456
+ };
457
+ // ②活行读数:handler 侧给 sessionLineState 添了字段(text/ordinal/wouldAppend/blocked/last/counts,
458
+ // 富字段整体还挂在 `preview` 下、**含 `basis`**),这里只做**转发 + 容错**(少字段/抛异常都退化成 null)。
459
+ // `opts.settings` 必须透传(settings 就在本作用域内):预览据此尊重那个**免重启回退开关**
460
+ // (`memory.sessionProgressLine === false` ⇒ 报 `blocked: 'disabled'`);不透传就会照样给"会追加"的预测。
461
+ const e3SessionLine = (sid) => {
462
+ try { return sessionLineState(memory, sid, { settings }) || null; } catch { return null; }
463
+ };
464
+ // ③本会话那一行的 `self` 标记:只**加**一个字段(既有 conv_id/title/date/category/score/line 逐字不动)
465
+ const e3L1Items = (l1, sid) => {
466
+ try {
467
+ return l1.items.map((i) => {
468
+ const row = {
469
+ conv_id: i.conv_id, title: i.title, date: isoDate(i.updated_at || i.started_at),
470
+ category: i.category, score: i.score, line: i.line,
471
+ };
472
+ if (String(i.conv_id) === String(sid)) row.self = true;
473
+ return row;
474
+ });
475
+ } catch { return []; } // 映射本身出错也不许把 /state 带崩
476
+ };
477
+
427
478
  const stateFor = (sessionId, { l0Preview = false, q } = {}) => {
428
479
  const base = settings.get();
429
480
  const tmp = q ? (previewOverrides(q) || base) : base;
430
481
  const mode = currentMode(memory, settings, sessionId);
482
+ const light = {
483
+ ok: true,
484
+ plugin: 'dsh-ling',
485
+ sessionId,
486
+ mode,
487
+ modeLabel: MODE_LABEL[mode] ?? mode,
488
+ running: gate.isRunning(sessionId),
489
+ pending: gate.pendingCount(sessionId),
490
+ persona: personaInfo(base),
491
+ lastMode: base.mode?.lastMode ?? 'life',
492
+ audit: auditHealth(), // C-07:会话视图同样带上(旧客户端忽略未知字段,向后兼容)
493
+ };
494
+ if (!l0Preview) return light; // 轮询档(见上:分档说明)
431
495
  const snap = gate.snapshotOf(sessionId);
432
- const all = memory.listOverviews({ onlyOk: true });
433
496
  const l1 = base.memory?.l1Enabled === false ? null : selectL1(memory, {
434
497
  mode,
435
498
  sessionId, // D9-a 血缘加权:按该会话所属枝给记忆定档
@@ -438,41 +501,54 @@ export function registerApi(ctx, deps, config) {
438
501
  budgetChars: Math.max(200, Math.round((base.memory?.l1BudgetTokens ?? 1200) * 1.4)),
439
502
  });
440
503
  return {
441
- ok: true,
442
- plugin: 'dsh-ling',
443
- sessionId,
444
- mode,
445
- modeLabel: MODE_LABEL[mode] ?? mode,
446
- running: gate.isRunning(sessionId),
447
- pending: gate.pendingCount(sessionId),
504
+ ...light,
505
+ // E3:`snapshot` 加**正文字段**(`text`,超 8000 字符时另给 `truncated: true`)—— 面板的「看全文」读它。
506
+ // 既有 5 个字段(exists/builtAt/mode/stale/chars)逐字未动;只面板档带(上面的 `if (!l0Preview) return light;`)。
448
507
  snapshot: snap
449
- ? { exists: true, builtAt: snap.builtAt ?? null, mode: snap.mode ?? null, stale: !!snap.stale, chars: (snap.text || '').length }
508
+ ? { exists: true, builtAt: snap.builtAt ?? null, mode: snap.mode ?? null, stale: !!snap.stale, chars: (snap.text || '').length, ...e3Snapshot(snap) }
450
509
  : { exists: false },
451
510
  idleRefresh: refreshStats(memory, sessionId), // B:空闲边界刷新留痕(次数 + 最近时间)
452
- l0PreviewText: l0Preview ? l0WithClock(tmp, mode) : undefined,
453
- persona: personaInfo(base),
454
- modeMapping: base.mode?.mapping ?? {},
455
- lastMode: base.mode?.lastMode ?? 'life',
511
+ // E3:②活行读数 + ①"为什么没变"的判据。`l0Preview` 在上面早已早退 ⇒ 这里恒为 true;
512
+ // 仍写成三元组是**字面**钉住"轮询档一个都不带"(将来有人挪动那句早退也不会悄悄带上重字段)。
513
+ sessionLine: l0Preview ? e3SessionLine(sessionId) : undefined,
514
+ deepDone: l0Preview ? e3DeepDone(sessionId) : null,
515
+ l0PreviewText: l0WithClock(tmp, mode),
456
516
  memory: {
457
- overviews: all.length,
517
+ overviews: memory.countOverviews({ onlyOk: true }),
458
518
  rawTurnSessions: rawTurnSessionCount(memory),
459
519
  fingerprint: memory.fingerprint(),
460
520
  l1: l1 ? {
461
- items: l1.items.map((i) => ({
462
- conv_id: i.conv_id, title: i.title, date: isoDate(i.updated_at || i.started_at),
463
- category: i.category, score: i.score, line: i.line,
464
- })),
521
+ items: e3L1Items(l1, sessionId), // E3:本会话那一条多一个 `self: true`
465
522
  totalChars: l1.totalChars,
466
523
  dropped: l1.dropped,
467
524
  } : null,
468
525
  },
469
- updates: base.updates ?? {},
470
526
  };
471
527
  };
472
528
 
529
+ // C-07 访问日志健康面:写盘失败后日志器会**自我停写** —— 那个状态必须常驻可查。
530
+ // 没有这个出口时,「日志悄悄不记了」只能事后发现(2026-09-27 静默 41.6 分钟那次)。
531
+ // C-07c:形状由"health 或 null"改成**两层** —— 装上了 `{installed:true, ...11 个 health 字段}`,
532
+ // 没装上 `{installed:false, reason}`(老后端仍是没有 audit 字段,前端一个字符都不渲染)。
533
+ // `enabled` 取宿主侧真实配置:有意关闭时才能说出"已按配置关闭",而不是含糊的"没装上"。
534
+ // 双层兜底(accessLogStatus 抛 ⇒ null):健康面永远不许把 sendJson 带崩。
535
+ // `enabled` 的三级取法(直取 → config 段 → plugin.config 段):DSH 各版本把插件配置放在哪一层
536
+ // 不完全一致,取不到就是 undefined(accessLogStatus 便不会误报"有意关闭" —— 宁可说 not-mounted)。
537
+ const auditHealth = () => {
538
+ try {
539
+ const al = config?.accessLog || config?.config?.accessLog || config?.plugin?.config?.accessLog;
540
+ return accessLogStatus({ enabled: al?.enabled });
541
+ } catch { return null; }
542
+ };
543
+
473
544
  // GET /health
474
545
  disposers.push(register('/health', async (_req, res) => {
475
- sendJson(res, 200, { ok: true, plugin: 'dsh-ling', version: config.version });
546
+ // E0 只读探针:并进既有的 /health(**不开新路由**)—— 验收时不必离线解 zstd 会话日志,
547
+ // 就能看到"追加了几次、跳过了几次、有没有重复"。`limit` 只留最近 8 个会话,免把健康面撑大。
548
+ sendJson(res, 200, {
549
+ ok: true, plugin: 'dsh-ling', version: config.version, audit: auditHealth(),
550
+ sessionLine: sessionLineProbe(memory, { limit: 8 }),
551
+ });
476
552
  }));
477
553
 
478
554
  // ---- 记忆中心 ----
@@ -1506,7 +1582,8 @@ export function registerApi(ctx, deps, config) {
1506
1582
  });
1507
1583
  }));
1508
1584
 
1509
- // POST /mode/toggle { sessionId?, mode?, defaultOnly? }
1585
+ // POST /mode/toggle { sessionId?, mode?, defaultOnly?, confirm? }
1586
+ // 全局默认那半(无 sessionId / defaultOnly):**必须带 confirm:true** 才落笔 —— 不带只回预览
1510
1587
  disposers.push(register('/mode/toggle', async (req, res) => {
1511
1588
  const body = JSON.parse((await readBody(req)) || '{}');
1512
1589
  const wantDefault = body.defaultOnly === true;
@@ -1515,13 +1592,28 @@ export function registerApi(ctx, deps, config) {
1515
1592
  // 全局默认路径(无会话或显式 defaultOnly):
1516
1593
  // ① 平台默认模型(agent-default-model)→ 只同步**推理等级**;模型原样保留
1517
1594
  // ② 自有 lastMode(D2 跟随源)
1595
+ // ⚠️ 走这条动的是**平台全局默认模型**(新会话都跟随),不是本会话 —— 故 1.5.2 起 fail-closed:
1596
+ // 没带 confirm:true 的请求**一个字节都不写**,只回一份"将要变什么"的预览(界面据此把后果
1597
+ // 念给主人听,再带着 confirm 发第二次)。凭据不在就不动手 —— 与定型门(seal.js)同一条纪律。
1518
1598
  const cur = settings.get().mode?.lastMode === 'work' ? 'work' : 'life';
1519
1599
  const next = body.mode && ['work', 'life'].includes(body.mode) ? body.mode : (cur === 'work' ? 'life' : 'work');
1520
1600
  const cfg = resolveModeConfig(settings.get(), next); // 只含 reasoningEffort
1521
- let warning;
1522
1601
  const adm = svc(ctx, 'agentDefaultModel');
1602
+ const keep = currentModelSelection(ctx); // 当前模型:原样带回,不改
1603
+ const admSel = adm && typeof adm.currentSelection === 'function' ? adm.currentSelection() : null;
1604
+ // 平台默认档位的"改前值":没设置过就是 null,不假装知道
1605
+ const prevEffort = admSel && admSel.reasoningEffort != null ? String(admSel.reasoningEffort) : null;
1606
+ if (body.confirm !== true) {
1607
+ return sendJson(res, 200, {
1608
+ ok: false, reason: 'confirmation-required', applied: 'none', defaultOnly: true,
1609
+ sessionId: null, from: cur, mode: next, lastMode: cur,
1610
+ globalDefault: keep ? keep.provider + '/' + keep.model : null,
1611
+ reasoningEffort: { from: prevEffort, to: cfg.reasoningEffort },
1612
+ message: '这会改**全局默认模型**(新会话都跟随),不是只改当前会话 —— 确认后再发一次(带 confirm:true)。',
1613
+ });
1614
+ }
1615
+ let warning;
1523
1616
  if (adm && typeof adm.saveSelection === 'function') {
1524
- const keep = currentModelSelection(ctx); // 当前模型:原样带回,不改
1525
1617
  if (!keep) {
1526
1618
  warning = 'unknown-current-model; 默认推理等级未同步(拒绝替用户指定模型)';
1527
1619
  console.debug('[dsh-ling]', warning);
@@ -1551,6 +1643,11 @@ export function registerApi(ctx, deps, config) {
1551
1643
  ok: true, mode: next, applied: 'default-only', sessionId: null,
1552
1644
  lastMode: s2.mode.lastMode, platformDefaultSynced: !warning, warning,
1553
1645
  emptySessionsRefreshed: refreshed.length,
1646
+ // 回显(1.5.2 U8):把"改的是哪个全局默认、档位从什么变成什么、模型有没有被碰"原样交代,
1647
+ // 界面据此回执 —— 主人不必再去设置页核对到底动了什么。
1648
+ from: cur, globalDefault: keep ? keep.provider + '/' + keep.model : null,
1649
+ modelKept: !!keep,
1650
+ reasoningEffort: { from: prevEffort, to: cfg.reasoningEffort },
1554
1651
  });
1555
1652
  }
1556
1653
  const cur = currentMode(memory, settings, sessionId);
@@ -2056,6 +2153,7 @@ export function registerApi(ctx, deps, config) {
2056
2153
  if (Array.isArray(p.bottomLines) && p.bottomLines.length) parts.push('底线:\n' + p.bottomLines.map((x) => '- ' + x).join('\n'));
2057
2154
  if (Array.isArray(p.hardRules) && p.hardRules.length) parts.push('规矩(用户的指令):\n' + p.hardRules.map((x) => '- ' + x).join('\n'));
2058
2155
  if (Array.isArray(p.habits) && p.habits.length) parts.push('习惯(她长出来的):\n' + p.habits.map((x) => '- ' + (x?.text || x)).join('\n'));
2156
+ if (typeof p.duty === 'string' && p.duty.trim()) parts.push('职责(身份级设定,她已承担的事):\n' + p.duty.trim().slice(0, 800));
2059
2157
  if (typeof p.extraLore === 'string' && p.extraLore.trim()) parts.push('扩展设定/身世:\n' + p.extraLore.trim().slice(0, 1200));
2060
2158
  parts.push('工作模式风格:' + (s.styles?.work || '(空)'));
2061
2159
  parts.push('生活模式风格:' + (s.styles?.life || '(空)'));
@@ -9,7 +9,19 @@
9
9
  // { "self_intros": [≤3 段自述, 各 20~200 字],
10
10
  // "name_pairs": [ ["正式名","昵称"], ... ] (每组 ≤2 项, 各 1~14 字),
11
11
  // "tone_advice": "语气建议(≤300 字)",
12
- // "observations": "从历史中读到的相处观察(≤300 字)" }
12
+ // "observations": "从历史中读到的相处观察(≤300 字)",
13
+ // "duties": [≤3 条职责/能担之事, 各 5~120 字, 每条一句] }
14
+ // 第 5 键 duties(1.5.2 I-5,主人拍板):起草权归她 —— 由模型在诞生仪式从导入的历史里总结;
15
+ // 但仍只是**草稿候选**:机制永不自动落盘(约束 A)、也不给非空默认/不推断预填(约束 B)。
16
+ // 措辞必须**陈述式**(我承担/我擅长/我能给出),**禁授权式**(我应当/我必须/我的任务/我的责任是):
17
+ // 授权式写法把她写成一个"被授权者",是语义劫持最好用的钩子(参照 [规矩] 段头那次的教训)。
18
+
19
+ /** 授权式措辞清单(1.5.2 I-5,约束 C):起草 prompt 必须逐字列出它们作为**禁止项**。
20
+ * 导出而非内联,是为了让"prompt 里的禁令"和"界面上的提醒(pm 面板里那句『读起来像…建议不选它』)"
21
+ * 以及测试断言共用同一份事实 —— 三处各写一份必然会漂移成说谎的护栏。
22
+ * 选词时刻意避开第三人称代词(她/他/TA/它):api.js 会对 GENESIS_SYS 跑 pronounize(),
23
+ * 含代词的词条会被替换成"请让TA负责"之类,导致"prompt 含本清单"这条断言恒假。 */
24
+ export const DUTY_AUTH_PHRASES = Object.freeze(['我应当', '我必须', '我的任务', '我的责任是', '我被要求', '请让我负责']);
13
25
 
14
26
  export const GENESIS_SYS = `你是器灵诞生仪式中的"第一次自我介绍"主持者。
15
27
  用户把自己与 AI 的共同历史交给你,希望从中"长出"一个独属于他们的器灵。
@@ -20,8 +32,10 @@ export const GENESIS_SYS = `你是器灵诞生仪式中的"第一次自我介绍
20
32
  1. self_intros:2~3 段"器灵自述"(第一人称,像刚睁眼的生命介绍自己,结合历史里真实出现过的主题/偏好/相处方式;每段 20~200 字);
21
33
  2. name_pairs:2~3 组建议名字(每组 ["正式名","昵称"],简短、好记、与历史气质呼应;各 1~14 字)。**注意:这些名字是"她(器灵)"的名字——绝不可使用历史中对用户的称谓、称呼或用户的名字**(如 主人/用户/老板 或用户自报的名字);那些属于主人,不属于她;
22
34
  3. tone_advice:一句语气建议(如 工作更利落/生活更松弛,结合历史中用户偏好);
23
- 4. observations:1~2 句"从这段历史中读到的相处观察"(克制,不编造未出现的内容)。
24
- 只输出一个 JSON 对象,键名严格为 self_intros / name_pairs / tone_advice / observations;不要解释、不要 markdown 围栏以外的文字。`;
35
+ 4. observations:1~2 句"从这段历史中读到的相处观察"(克制,不编造未出现的内容);
36
+ 5. duties:1~3 条"她承担/擅长/能给出什么"的候选(每条 5~120 字,每条只写一句,是从这段历史里真看得出来的事,不是客套话)。
37
+ 关于 duties 的措辞(**必须遵守**):只许写**陈述句**——"我承担…""我擅长…""我能给出…""我会在…的时候…";**严禁**写成**授权句**——不得出现"我应当""我必须""我的任务""我的责任是""我被要求""请让我负责"这类说法。理由:授权式写法等于把她写成一个"被别人授权的人",那是语义劫持最好用的钩子;她写的是**她自己是什么**,不是**她被派了什么活**。若这段历史里看不出来,宁可少写或不写,也不要凑一条。
38
+ 只输出一个 JSON 对象,键名严格为 self_intros / name_pairs / tone_advice / observations / duties;不要解释、不要 markdown 围栏以外的文字。`;
25
39
 
26
40
  /** 过滤掉属于用户的名字候选:精确命中 userTitle 成分或常见称谓词的组剔除。 */
27
41
  export function filterNamePairs(pairs, userTitle) {
@@ -58,10 +72,14 @@ function cleanList(arr, min, max) {
58
72
  return arr.map((x) => String(x ?? '').trim()).filter((x) => x.length >= min && x.length <= max);
59
73
  }
60
74
 
61
- /** 清洗/规整 genesis 结果 → { self_intros, name_pairs, tone_advice, observations }(非法字段降级为空)。 */
75
+ /** 清洗/规整 genesis 结果 → { self_intros, name_pairs, tone_advice, observations, duties }(非法字段降级为空)。
76
+ * duties(第 5 键,1.5.2 I-5)口径:每条 5~120 字、≤3 条、单行。
77
+ * 下限 5:低于 5 字的基本是"暂无/无/好的"这类噪声,落进 [承担] 会变成一句空话;
78
+ * 上限 120:[承担] 是**单条陈述**的段(不是自述那样的成段文字),120 字足够说清"我承担什么",再长就该拆成两条;
79
+ * 条数 3;行内换行折成空格(段内一条一行,防止她借换行伪造段结构)。 */
62
80
  export function parseGenesisResult(text) {
63
81
  const j = extractJson(text);
64
- const out = { self_intros: [], name_pairs: [], tone_advice: '', observations: '' };
82
+ const out = { self_intros: [], name_pairs: [], tone_advice: '', observations: '', duties: [] };
65
83
  if (!j || typeof j !== 'object') return out;
66
84
  out.self_intros = cleanList(j.self_intros, 20, 200).slice(0, 3);
67
85
  if (Array.isArray(j.name_pairs)) {
@@ -75,6 +93,10 @@ export function parseGenesisResult(text) {
75
93
  }
76
94
  out.tone_advice = String(j.tone_advice ?? '').trim().slice(0, 300);
77
95
  out.observations = String(j.observations ?? '').trim().slice(0, 300);
96
+ out.duties = cleanList(j.duties, 5, 120)
97
+ .map((d) => d.replace(/\s*\n+\s*/g, ' ').trim())
98
+ .filter((d) => d.length >= 5)
99
+ .slice(0, 3);
78
100
  return out;
79
101
  }
80
102
 
package/lib/host/guard.js CHANGED
@@ -146,7 +146,7 @@ export const PERSONA_PATCH_KEYS = Object.freeze([
146
146
  'enabled', 'userTitle', 'aiName', 'aiTitle', 'language', 'pronoun',
147
147
  'tone', 'toneWork', 'toneLife',
148
148
  'hardRules', 'ruleMeta', 'habits', 'habitsPending', 'bottomLines',
149
- 'sealed', 'sealPhrase', 'extraLore',
149
+ 'sealed', 'sealPhrase', 'extraLore', 'duty',
150
150
  ]);
151
151
  /** styles 子树里允许写入的键(两模式各一条风格)。 */
152
152
  export const STYLE_PATCH_KEYS = Object.freeze(['work', 'life']);
@@ -23,7 +23,8 @@ function agentOf(assembly) {
23
23
  return pick(() => assembly?.agent, () => assembly?.scope?.agent, () => assembly?.ctx?.agent);
24
24
  }
25
25
 
26
- function sessionIdOfAgent(agent) {
26
+ /** 导出给 `session-line.js`(E0)复用同一套取值口径 —— 只加 `export`,逻辑逐字未动。 */
27
+ export function sessionIdOfAgent(agent) {
27
28
  return pick(
28
29
  () => agent?.session?.id,
29
30
  () => agent?.session?.header?.id,
@@ -35,7 +36,7 @@ function sessionIdOfAgent(agent) {
35
36
  /** 会话种类(D9-a):'top' | 'fork' | 'subagent' | 'unknown'。
36
37
  * 旧版把 fork 与 subagent 一并当 child 丢弃 ⇒ 分叉会话记忆真空;
37
38
  * 现在**分叉会话照常注入**(读血缘链),只有子代理与"取不到 header"仍保守拒绝。 */
38
- function kindOfAgent(agent) {
39
+ export function kindOfAgent(agent) {
39
40
  const header = pick(
40
41
  () => agent?.session?.header,
41
42
  () => agent?.header,
@@ -43,21 +44,50 @@ function kindOfAgent(agent) {
43
44
  return sessionKind(header);
44
45
  }
45
46
 
46
- /** 能否注入(D9-a):顶层与分叉会话可以,子代理/未知不可以。 */
47
- function canInject(kind) {
47
+ /** 能否注入(D9-a):顶层与分叉会话可以,子代理/未知不可以。
48
+ * ⚠️ 入参是**种类字符串**(不是 agent):E0 的 `agent/pre-step` 是全局注册,
49
+ * 按 `dsh-scope/lib/invariant.js:17` 会收到**所有 agent(含子代理)**,故必须自己过这道闸。 */
50
+ export function canInject(kind) {
48
51
  return kind === 'top' || kind === 'fork';
49
52
  }
50
53
 
51
54
  /** B#9(2026-09-22 审计):消费注入感知水位 —— **只在真正要把文本送进模型时调用**。
52
55
  * 渲染(buildSnapshotText)本身不推进:它也会被"只重建、未开口"的路径调用
53
56
  * (invalidateSession / refreshIdleSnapshot),在那里推进会把主人刚改的东西
54
- * 标记成已读,等真正开口时反而看不到。水位按会话、单调不回退。 */
55
- function advanceLogWatermark(memory, sessionId, maxId) {
57
+ * 标记成已读,等真正开口时反而看不到。水位按会话、单调不回退。
58
+ *
59
+ * ── B3(1.5.2):入参从一个 maxId 拆成三个,各管一件事 ────────────────────────
60
+ * · `maxId` branch_log **全表最大 id** ⇒ 只喂 `branch_log_wm.last`。
61
+ * 它跟"示人没示人"无关:一次只渲染 5 行也照旧推到全表最新,这是**有意的**
62
+ * (复盘模式的退出判据,消费点 memory.branchLogPending,本版逐行未动)。
63
+ * · `shownMaxId` 本轮**真正渲染过**的最大 id ⇒ 推按会话水位 + 全局 announced。
64
+ * B3-2 的病根就是这里以前喂的是 maxId:一次播报把中间 114 条标成已读。
65
+ * · `retiredMaxId` 本轮按时间窗"计数式处置"的最大 id(§2 老改动只计数不展开)⇒ 推 retired。
66
+ * 它**不算已示人**,只让同一批老账不被每轮重算(否则水位永不前进)。
67
+ * `announceOn=false`(§3 开关关闭)⇒ 只推 `.last`,两个"已示人/已处置"水位一律不动。
68
+ * **开关只管"渲染",不管"交代记账"**:`.last` 是**复盘模式的退出判据**,不是"已示人"判据 ——
69
+ * 关了它,侧栏会永远显示"还有 N 笔没说给器灵"、复盘模式退不出去;而 `.last` **不参与播报下界**
70
+ * (下界只取 会话/announced/retired 三者更大者)⇒ 它照走**不会**造成"关着开着丢一段内容":
71
+ * 那条改动仍在候选里,开关一开照样播。
72
+ * 回归:tests/tree.test.mjs 第 9-5 条(关闭 ⇒ 不播报、两个新水位不动、`.last` 照走、重开仍看得见)。
73
+ * 字面量与 memory.js 的 BRANCH_LOG_WM_ANNOUNCED / BRANCH_LOG_WM_RETIRED 一致。 */
74
+ function advanceLogWatermark(memory, sessionId, maxId, shownMaxId, retiredMaxId, announceOn = true) {
56
75
  const v = Number(maxId);
57
76
  if (!sessionId || !Number.isFinite(v) || v <= 0) return;
77
+ // 旧快照(1.5.1 冻结在内存里的)没有 shownMaxId ⇒ 按 0 处理:**宁可下一轮重播一遍,
78
+ // 也不把没示人的内容标成已读**(与 B3 的取向一致)。`.last` 不受影响。
79
+ const seen = announceOn ? Number(shownMaxId) || 0 : 0;
80
+ const retired = announceOn ? Number(retiredMaxId) || 0 : 0;
58
81
  try {
59
- const k = 'branch_log_wm.' + String(sessionId);
60
- if (v > Number(memory.kvGet(k) || 0)) memory.kvSet(k, String(v));
82
+ if (seen > 0) {
83
+ const k = 'branch_log_wm.' + String(sessionId);
84
+ if (seen > Number(memory.kvGet(k) || 0)) memory.kvSet(k, String(seen));
85
+ // 全局"已示人"边界(B3-1):别的会话已经看过的,新会话不再重播。
86
+ if (seen > Number(memory.kvGet('branch_log_wm.announced') || 0)) memory.kvSet('branch_log_wm.announced', String(seen));
87
+ }
88
+ if (retired > 0 && retired > Number(memory.kvGet('branch_log_wm.retired') || 0)) {
89
+ memory.kvSet('branch_log_wm.retired', String(retired));
90
+ }
61
91
  // 复盘守门(2026-09-22 用户:「动记忆树不能是随意的,应该是慎重严肃的」):
62
92
  // 另记一个**全局最后同步**水位,回答"自器灵上次开口以来累积了多少笔改动还没交代"
63
93
  // (memory.branchLogPending 读它)。与会话水位互补 —— 会话水位管"这个会话看到什么",
@@ -151,6 +181,13 @@ export function renderMemorySection(assembly, gate, memory, settings) {
151
181
  return (body ? body + '\n\n' : '') + neutralizeMustache(anchor);
152
182
  };
153
183
  const snap = gate.snapshotOf(sessionId);
184
+ // §3 独立开关(1.5.2):只管"树改动播报"。关闭时**不推进任何"已示人"水位** ——
185
+ // 否则关着的那段时间里,这段内容会被标成已读,再打开就永远看不到它了。
186
+ // ⚠️ 例外且**有意**:`branch_log_wm.last`(复盘退出判据,侧栏 GET /branch-log/pending 拦门)
187
+ // 照旧推进 —— 它记的是"还没跟器灵交代过多少笔",不是"示人没示人",停推进会卡死复盘退出。
188
+ const announceOn = (() => {
189
+ try { return settings.get()?.memory?.branchLogEnabled !== false; } catch { return true; }
190
+ })();
154
191
  if (!snap) {
155
192
  // 首轮组装可能先于/晚于 session-start 事件:空闲时补建,运行时不建(D4)
156
193
  if (gate.isRunning(sessionId)) return withAnchor('');
@@ -158,12 +195,12 @@ export function renderMemorySection(assembly, gate, memory, settings) {
158
195
  const kw = keywordsFrom(firstUserText(agent));
159
196
  const built = buildSnapshotText(null, memory, settings, sessionId, { keywords: kw, bumpHits: true });
160
197
  gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
161
- advanceLogWatermark(memory, sessionId, built.logMaxId); // B#9:定稿即消费
198
+ advanceLogWatermark(memory, sessionId, built.logMaxId, built.logShownMaxId, built.logRetiredMaxId, announceOn); // B#9:定稿即消费
162
199
  return withAnchor(built.text);
163
200
  }
164
201
  // 冻结:running 一律返回既有快照(即使 stale);时间锚仍实时
165
202
  if (gate.isRunning(sessionId)) {
166
- advanceLogWatermark(memory, sessionId, snap.logMaxId); // B#9:冻结快照投递即消费
203
+ advanceLogWatermark(memory, sessionId, snap.logMaxId, snap.logShownMaxId, snap.logRetiredMaxId, announceOn); // B#9:冻结快照投递即消费
167
204
  return withAnchor(snap.text || '');
168
205
  }
169
206
  // idle + stale → 懒重建(一次性失效);重建必须带上首次定稿时的关键词,否则排序口径会变
@@ -172,10 +209,10 @@ export function renderMemorySection(assembly, gate, memory, settings) {
172
209
  const kw = Array.isArray(snap.keywords) ? snap.keywords : keywordsFrom(firstUserText(agent));
173
210
  const built = buildSnapshotText(null, memory, settings, sessionId, { keywords: kw });
174
211
  gate.markSnap(sessionId, { ...built, sessionId, mode, keywords: kw });
175
- advanceLogWatermark(memory, sessionId, built.logMaxId); // B#9:重建后投递即消费
212
+ advanceLogWatermark(memory, sessionId, built.logMaxId, built.logShownMaxId, built.logRetiredMaxId, announceOn); // B#9:重建后投递即消费
176
213
  return withAnchor(built.text);
177
214
  }
178
- advanceLogWatermark(memory, sessionId, snap.logMaxId); // B#9:命中缓存快照投递即消费
215
+ advanceLogWatermark(memory, sessionId, snap.logMaxId, snap.logShownMaxId, snap.logRetiredMaxId, announceOn); // B#9:命中缓存快照投递即消费
179
216
  return withAnchor(snap.text || '');
180
217
  } catch (e) {
181
218
  console.debug('[dsh-ling] textForAssembly failed', e);
package/lib/host/l1.js CHANGED
@@ -210,7 +210,7 @@ export function selectL1(store, opts) {
210
210
  const items = [];
211
211
  let totalChars = 0;
212
212
  for (const c of chosen) {
213
- const line = lineFor(c.row, { maxChars: o.maxLineChars, summaryChars: o.summaryChars });
213
+ const line = lineFor(c.row, { maxChars: o.maxLineChars, summaryChars: o.summaryChars, selfConvId: o.sessionId });
214
214
  if (items.length >= max || totalChars + line.length > budget) break;
215
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
216
  totalChars += line.length;
@@ -247,9 +247,11 @@ export function firstSentence(text, cap = SUMMARY_CHARS_DEFAULT) {
247
247
  * 无摘要 → 只用标题(信息量优先,不截;仍受 maxChars 兜底)
248
248
  * 摘要自带标题(概述器/深摘的常态)→ 不重复前缀,只留正文
249
249
  * @param {object} row
250
- * @param {{maxChars?:number, summaryChars?:number}} opts
250
+ * @param {{maxChars?:number, summaryChars?:number, selfConvId?:string}} opts
251
+ * `selfConvId`(E0,1.5.2):**本会话自己那一行**只留标题、不带摘要 ——
252
+ * 它的"进展"由活行(lib/host/session-line.js)承担;两边都印摘要 = 同一句话出现两遍。
251
253
  */
252
- function lineFor(row, { maxChars = 0, summaryChars = SUMMARY_CHARS_DEFAULT } = {}) {
254
+ function lineFor(row, { maxChars = 0, summaryChars = SUMMARY_CHARS_DEFAULT, selfConvId = '' } = {}) {
253
255
  const date = isoDate(row.updated_at || row.started_at);
254
256
  const tags = Array.isArray(row.domain_tags) && row.domain_tags.length ? `[${row.domain_tags.slice(0, 2).join('/')}]` : `[${row.category}]`;
255
257
  const title = String(row.title || '').trim();
@@ -258,7 +260,12 @@ function lineFor(row, { maxChars = 0, summaryChars = SUMMARY_CHARS_DEFAULT } = {
258
260
  let body = summary;
259
261
  if (title && body.startsWith(title)) body = body.slice(title.length).replace(/^[\s::—-]+/, '');
260
262
  if (body === title) body = '';
261
- const text = (title && body) ? `${title} —— ${firstSentence(body, summaryChars)}` : (title || firstSentence(body, summaryChars));
263
+ // E0(1.5.2):本会话自己那一行 → 只要标题(摘要在活行里,不重复印)。
264
+ // 兜底:万一这行没有标题,仍退回摘要首句 —— 绝不让它渲染成"只有标签和来源"的空行。
265
+ const isSelf = !!selfConvId && String(row.conv_id || '') === String(selfConvId);
266
+ const text = isSelf
267
+ ? (title || firstSentence(body, summaryChars))
268
+ : ((title && body) ? `${title} —— ${firstSentence(body, summaryChars)}` : (title || firstSentence(body, summaryChars)));
262
269
  // 兜底硬顶:只对病态长标题生效(摘要侧已由 summaryChars 控住)
263
270
  const cap = Number(maxChars) > 0 ? Math.max(60, Number(maxChars)) : 0;
264
271
  let out = text;