@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.
Files changed (51) hide show
  1. package/ACCESS-DESIGN.md +119 -0
  2. package/CHANGELOG.md +627 -0
  3. package/DESIGN.md +461 -0
  4. package/LICENSE +201 -0
  5. package/NOTICE +7 -0
  6. package/README.md +236 -0
  7. package/cordis.patch.yml +16 -0
  8. package/lib/audit/access-log.js +533 -0
  9. package/lib/audit/index.js +92 -0
  10. package/lib/audit/patch-spec.js +195 -0
  11. package/lib/client.js +5069 -0
  12. package/lib/host/api.js +2584 -0
  13. package/lib/host/archive.js +194 -0
  14. package/lib/host/autotree.js +131 -0
  15. package/lib/host/backfill.js +267 -0
  16. package/lib/host/classify.js +10 -0
  17. package/lib/host/clock-skill.js +141 -0
  18. package/lib/host/clock.js +132 -0
  19. package/lib/host/corpus-suggest.js +139 -0
  20. package/lib/host/deepsummary.js +338 -0
  21. package/lib/host/dsweb-summary.js +225 -0
  22. package/lib/host/feedback.js +119 -0
  23. package/lib/host/freeze.js +144 -0
  24. package/lib/host/genesis.js +176 -0
  25. package/lib/host/guard.js +184 -0
  26. package/lib/host/habit-gen.js +273 -0
  27. package/lib/host/import-file.js +291 -0
  28. package/lib/host/import-log.js +129 -0
  29. package/lib/host/inject-common.js +25 -0
  30. package/lib/host/inject.js +285 -0
  31. package/lib/host/l1.js +282 -0
  32. package/lib/host/lifecycle.js +367 -0
  33. package/lib/host/memory.js +2389 -0
  34. package/lib/host/mode.js +101 -0
  35. package/lib/host/persona.js +214 -0
  36. package/lib/host/retitle.js +214 -0
  37. package/lib/host/rules.js +195 -0
  38. package/lib/host/scan-dsweb.js +111 -0
  39. package/lib/host/seal.js +17 -0
  40. package/lib/host/settings-file.js +49 -0
  41. package/lib/host/snapshot.js +168 -0
  42. package/lib/host/suggestions.js +47 -0
  43. package/lib/host/summarizer.js +103 -0
  44. package/lib/host/tone-advice.js +112 -0
  45. package/lib/host/tools.js +1067 -0
  46. package/lib/host/tree-ops.js +330 -0
  47. package/lib/host/util.js +165 -0
  48. package/lib/host/zstd.js +63 -0
  49. package/lib/index.js +263 -0
  50. package/package.json +69 -0
  51. package/tools/apply-access-log-patch.mjs +295 -0
@@ -0,0 +1,103 @@
1
+ // dsh-ling host — DSH 会话概述器 v1(轻量)。
2
+ // 把 dsh_turns_raw 中每个顶层会话聚合成 source='dsh' 的 conv_overview:
3
+ // title = 首条真人消息单行截断;summary = 首问摘要 + 轮次;类别/关键词启发式。
4
+ // 增量:每会话水位 kv('sumdsh:<sid>') = 已概述的最大 seq;有新增才重建。
5
+ // 无 LLM 深摘要(v1);原文保留在 dsh_turns_raw,供 M2 概述升级。
6
+ import { categorizeTitle } from './classify.js';
7
+ import { heuristicTitle, TITLE_MAX_CHARS } from './retitle.js';
8
+ import { keywordsFrom } from './inject-common.js';
9
+ import { toIso } from './util.js';
10
+
11
+ const MIN_MESSAGES = 2;
12
+ const MIN_CHARS = 40;
13
+ const TITLE_MAX = TITLE_MAX_CHARS; // D2(2026-09-17):46 → 58。旧值把一句话切成半句;此值仅作启发式失败时的兜底
14
+ const SUMMARY_FIRST_MAX = 140;
15
+
16
+ export function summarizeDsh(memory, { force = false } = {}) {
17
+ const groups = memory.db.prepare(
18
+ `SELECT session_id, COUNT(*) AS n, COALESCE(SUM(LENGTH(text)),0) AS chars,
19
+ MIN(ts) AS t0, MAX(ts) AS t1, MAX(seq) AS maxSeq
20
+ FROM dsh_turns_raw WHERE session_id NOT LIKE 'import:%' GROUP BY session_id`,
21
+ ).all();
22
+ const stats = { sessions: groups.length, created: 0, updated: 0, skipped: 0, watermarkSet: 0, archivedSkipped: 0 };
23
+ // 归档即清理(2026-09-16 用户定:清掉归档会话,未归档的测试会话不动):
24
+ // 已归档会话不再生成/重建概述 —— 否则清掉的会被下一轮概述器复活。force 仍可强制重建(CLI 显式动作)。
25
+ const archived = new Set(
26
+ memory.db.prepare('SELECT session_id FROM session_meta WHERE archived=1').all().map((r) => String(r.session_id)),
27
+ );
28
+
29
+ const firstUserStmt = memory.db.prepare(
30
+ `SELECT text FROM dsh_turns_raw WHERE session_id=? AND role='user' ORDER BY seq ASC LIMIT 1`,
31
+ );
32
+ const laterUserStmt = memory.db.prepare( // D2:首句是「你好」「下午好」这类时,往后取有实义的首句
33
+ `SELECT text FROM dsh_turns_raw WHERE session_id=? AND role='user' ORDER BY seq ASC LIMIT 6`,
34
+ );
35
+ const roleCountStmt = memory.db.prepare(
36
+ `SELECT role, COUNT(*) AS n FROM dsh_turns_raw WHERE session_id=? GROUP BY role`,
37
+ );
38
+
39
+ for (const g of groups) {
40
+ const sid = String(g.session_id);
41
+ if (!force && archived.has(sid)) {
42
+ stats.skipped += 1;
43
+ stats.archivedSkipped += 1;
44
+ continue;
45
+ }
46
+ const wmKey = 'sumdsh:' + sid;
47
+ const wm = Number(memory.kvGet(wmKey) || 0);
48
+ if (!force && wm >= Number(g.maxSeq)) {
49
+ stats.skipped += 1;
50
+ continue;
51
+ }
52
+ const roles = roleCountStmt.all(sid);
53
+ const users = Number((roles.find((r) => r.role === 'user') || {}).n || 0);
54
+ const asst = Number((roles.find((r) => r.role === 'assistant') || {}).n || 0);
55
+ const eligible = users >= 1 && users + asst >= MIN_MESSAGES && Number(g.chars) >= MIN_CHARS;
56
+ if (eligible) {
57
+ const first = String(firstUserStmt.get(sid)?.text || '').trim();
58
+ const oneLine = first.replace(/\s+/g, ' ');
59
+ // D2(2026-09-17):标题不再硬截首句。旧行为 60 条 dsh 里 26 条被切成半句话
60
+ // (「…继续走你的新生之路。 我刚刚对1.2.1的」),且「你好」「下午好」直接当标题。
61
+ // 三层:剥称呼/问候前缀 → 不好则往后取有实义的首句 → 在句读处收尾。
62
+ let title = heuristicTitle(oneLine);
63
+ if (!title) {
64
+ for (const t of laterUserStmt.all(sid).slice(1)) {
65
+ title = heuristicTitle(String(t.text || ''));
66
+ if (title) break;
67
+ }
68
+ }
69
+ if (!title) title = oneLine.slice(0, TITLE_MAX);
70
+ const summary = first.length > SUMMARY_FIRST_MAX ? oneLine.slice(0, SUMMARY_FIRST_MAX) + '…' : oneLine;
71
+ const category = categorizeTitle(title);
72
+ const keywords = keywordsFrom((title + ' ' + oneLine).slice(0, 400));
73
+ const existing = memory.overviewById('dsh', sid);
74
+ const deepDone = !!memory.kvGet('deep:' + sid);
75
+ memory.upsertOverview({
76
+ conv_id: sid,
77
+ source: 'dsh',
78
+ title: title || '(未命名会话)',
79
+ // 修 A:MIN/MAX(ts) 在混合形态下会被遗留数字串带偏 —— 落库前一并归一为 ISO
80
+ started_at: toIso(g.t0),
81
+ updated_at: toIso(g.t1),
82
+ domain_tags: [category === 'knowledge' ? '知识' : category === 'feeling' ? '生活' : '日常'],
83
+ category,
84
+ keywords,
85
+ // 已深摘的会话保留深摘要,不因增量刷新被浅版覆盖
86
+ summary: deepDone && existing ? existing.summary : `${summary} — ${users + asst} 条消息`,
87
+ // G2(2026-09-17):概述器**不再往库里写** heat/importance/last_hit_at/hit_count ——
88
+ // 这四个是本机用户态(置顶、命中、热度):旧行为每轮重建都写 0 且被 UPSERT 无条件覆盖,
89
+ // 使置顶的删除保护(importance>=1)与热度排序同时失效。新建行由 upsertOverview 填默认 0。
90
+ overview_ok: 1,
91
+ origin: 'dsh-summarizer-v1',
92
+ });
93
+ if (existing) stats.updated += 1;
94
+ else stats.created += 1;
95
+ } else {
96
+ stats.skipped += 1;
97
+ }
98
+ memory.kvSet(wmKey, String(g.maxSeq));
99
+ stats.watermarkSet += 1;
100
+ }
101
+ if (groups.length) memory.kvSet('memory_version', String(Date.now()));
102
+ return stats;
103
+ }
@@ -0,0 +1,112 @@
1
+ // dsh-ling host — 语气 P1:按记忆分类(知识↔生活)归纳语气候选(人审采纳,自由生长)。
2
+ // 语义(2026-09-11 作者拍板):
3
+ // - 语气/风格属"自性类"→ 成长通道,锁定下免手术;契约类(名字/称呼/底线/世界观)仍属手术区;
4
+ // - 只做可逆动作:语气仅在四档枚举间替换;风格文本只追加、不删除用户写过的字;
5
+ // - 采纳=用户按下的那一下(候选不落盘)。
6
+ import { extractJson } from './genesis.js';
7
+ import { isPlaceholderSummary, snippetFromRaw } from './genesis.js';
8
+
9
+ export const TONE_SET = ['natural', 'literary', 'concise', 'playful'];
10
+ export const TONE_LABEL = { natural: '自然亲切', literary: '文雅', concise: '简洁直接', playful: '活泼俏皮' };
11
+
12
+ /** 模型可能回中文/大写/近义词:统一归一到四档枚举,失败返回 ''(不猜)。 */
13
+ export const TONE_ALIAS = {
14
+ natural: 'natural', 自然: 'natural', 自然亲切: 'natural', 亲切: 'natural', 温和: 'natural',
15
+ literary: 'literary', 文雅: 'literary', 文学: 'literary', 典雅: 'literary', 书面: 'literary',
16
+ concise: 'concise', 简洁: 'concise', 简洁直接: 'concise', 直接: 'concise', 精炼: 'concise', 干练: 'concise',
17
+ playful: 'playful', 活泼: 'playful', 俏皮: 'playful', 活泼俏皮: 'playful', 轻松: 'playful', 幽默: 'playful',
18
+ };
19
+ export function normalizeTone(raw) {
20
+ const s = String(raw ?? '').trim();
21
+ if (!s) return '';
22
+ const lower = s.toLowerCase();
23
+ if (TONE_SET.includes(lower)) return lower;
24
+ if (TONE_ALIAS[s]) return TONE_ALIAS[s];
25
+ if (TONE_ALIAS[lower]) return TONE_ALIAS[lower];
26
+ for (const k of TONE_SET) if (lower.includes(k)) return k; // 如 "concise(简洁直接)"
27
+ for (const [k, v] of Object.entries(TONE_ALIAS)) if (s.includes(k)) return v;
28
+ return '';
29
+ }
30
+
31
+ /** 两侧取样权重:工作侧看知识,生活侧看情感与日常 */
32
+ export const TONE_SIDE_WEIGHTS = {
33
+ work: { knowledge: 1.0, daily: 0.3, feeling: 0.05 },
34
+ life: { feeling: 1.0, daily: 0.6, knowledge: 0.05 },
35
+ };
36
+ /** 置顶加成刻意小于最小类别差:只在同类内优先,不反转"工作侧看知识 / 生活侧看情感"的类别序。 */
37
+ export const TONE_IMPORTANCE_BONUS = 0.35;
38
+
39
+ export const TONE_ADVICE_SYS = `你是器灵的"语气观察员"。请阅读两组历史材料(工作侧 / 生活侧),分别判断用户更适应哪一种语气,并给出依据。
40
+ 四档语气(必须从中选择,不要创造新词):
41
+ - natural 自然亲切:口语化但不轻浮,专业问题先严谨再谈温度
42
+ - literary 文雅:文雅简洁,可适当用典,不堆砌辞藻
43
+ - concise 简洁直接:直接、精炼、少铺垫、结论先行
44
+ - playful 活泼俏皮:轻松活泼,适度俏皮,仍以有用为先
45
+ 要求:
46
+ 1. 依据必须来自材料里真实出现的痕迹(如反复要求结论先行、深夜更接受玩笑、讨厌冗长文言等),不要空话;
47
+ 2. 只输出一个 JSON 对象,键名严格为 work / life;每个值为 {"tone":"四档之一","note":"≤80 字语气注(她会怎么写自己)","evidence":"≤120 字判断依据"};
48
+ 3. 不要解释、不要 markdown 围栏以外的文字。`;
49
+
50
+ /** 按侧取样:类别权重 + 置顶加成,降序取前 limit;并做三级取用(深摘→概述→片段/线索)。 */
51
+ export function sampleToneRows(memory, { side = 'life', limit = 40, pronoun = '她' } = {}) {
52
+ const weights = TONE_SIDE_WEIGHTS[side] || TONE_SIDE_WEIGHTS.life;
53
+ const pool = memory.db.prepare(
54
+ 'SELECT conv_id, title, summary, keywords, category, importance, updated_at FROM conv_overview',
55
+ ).all();
56
+ const rows = pool.map((r) => ({
57
+ conv_id: String(r.conv_id),
58
+ title: r.title,
59
+ summary: String(r.summary || ''),
60
+ keywords: (() => { try { const v = JSON.parse(String(r.keywords || '[]')); return Array.isArray(v) ? v : []; } catch { return []; } })(),
61
+ category: String(r.category),
62
+ importance: Number(r.importance) || 0,
63
+ updated_at: String(r.updated_at || ''),
64
+ }))
65
+ .map((r) => ({ ...r, score: (weights[r.category] ?? 0.05) + (r.importance > 0 ? TONE_IMPORTANCE_BONUS : 0) }))
66
+ .sort((a, b) => (b.score - a.score) || b.updated_at.localeCompare(a.updated_at))
67
+ .slice(0, Math.max(1, Math.min(limit, 200)));
68
+ const out = rows.map((r) => {
69
+ const deep = memory.kvGet('deep.sum:' + r.conv_id);
70
+ let summary = '';
71
+ if (deep) summary = String(deep);
72
+ else {
73
+ const s = r.summary.trim();
74
+ if (s && !isPlaceholderSummary(s)) summary = s;
75
+ }
76
+ return {
77
+ title: r.title,
78
+ summary,
79
+ snippet: summary ? '' : snippetFromRaw(memory, r.conv_id, { pronoun }),
80
+ keywords: summary ? [] : r.keywords,
81
+ category: r.category,
82
+ };
83
+ });
84
+ return { rows: out, sampled: out.length, pool: pool.length };
85
+ }
86
+
87
+ /** 清洗模型输出 → { work:{tone,note,evidence}, life:{...} }(tone 走别名归一,失败置空) */
88
+ export function parseToneAdvice(text) {
89
+ const j = extractJson(text) || {};
90
+ const side = (a, b, c) => a || b || c || {};
91
+ const pick = (v) => {
92
+ const o = v && typeof v === 'object' ? v : {};
93
+ return {
94
+ tone: normalizeTone(o.tone),
95
+ note: String(o.note ?? '').trim().slice(0, 80),
96
+ evidence: String(o.evidence ?? '').trim().slice(0, 120),
97
+ };
98
+ };
99
+ return {
100
+ work: pick(side(j.work, j['工作'], j['工作侧'])),
101
+ life: pick(side(j.life, j['生活'], j['生活侧'])),
102
+ };
103
+ }
104
+
105
+ /** 风格文本追加:只追加、不删除;已含则原样返回(去重)。 */
106
+ export function appendStyleNote(existing, note) {
107
+ const cur = String(existing || '');
108
+ const add = String(note || '').trim();
109
+ if (!add) return cur;
110
+ if (cur.includes(add)) return cur;
111
+ return (cur.trimEnd() ? cur.trimEnd() + '\n' : '') + add;
112
+ }