@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,195 @@
|
|
|
1
|
+
// dsh-ling host — 规矩 / 习惯 域逻辑(2026-09-15 与用户定稿的二分)
|
|
2
|
+
//
|
|
3
|
+
// 原则:**指令直达,人格不直达。**
|
|
4
|
+
// - 规矩(rules)= 用户的指令,作用对象是「行为」→ 可直接写入;但**必须带原话**为凭据。
|
|
5
|
+
// - 习惯(habits)= 器灵自己长出来的一部分,作用对象是「我是谁」→ **不能由外部直接写入**,
|
|
6
|
+
// 只能经「提议 → 对方确认」落地。任一方都可提议,由另一方确认。
|
|
7
|
+
//
|
|
8
|
+
// 存储(settings.persona 内):
|
|
9
|
+
// hardRules: string[] 规矩正文(沿用旧字段名以兼容人格编辑器)
|
|
10
|
+
// ruleMeta: { [text]: {quote, at, sessionId, source} } 规矩的来源留痕(原话/时间/会话)
|
|
11
|
+
// habits: [{text, evidence, at, source}] 已定习惯
|
|
12
|
+
// habitsPending: [{id, text, evidence, byUser, at}] 待确认的习惯提议
|
|
13
|
+
//
|
|
14
|
+
// 本模块为纯域逻辑:只依赖传入的 settings(get/update),不做 IO、不碰 ctx,便于单测。
|
|
15
|
+
|
|
16
|
+
export const RULES_MAX = 12; // 超过则提示合并(注入面瘦身的硬约束)
|
|
17
|
+
export const HABITS_MAX = 8;
|
|
18
|
+
// 待确认队列上限(2026-09-16 用户定案:5)。
|
|
19
|
+
// 理由:珍贵的东西不该被摆成流水线 —— 满了就**明确驳回**新的生成请求并说明,而不是排队等她一辈子。
|
|
20
|
+
export const PENDING_MAX_DEFAULT = 5;
|
|
21
|
+
export const RULE_MAX_CHARS = 40;
|
|
22
|
+
export const QUOTE_MAX_CHARS = 200;
|
|
23
|
+
export const EVIDENCE_MAX_CHARS = 300;
|
|
24
|
+
|
|
25
|
+
function norm(text) {
|
|
26
|
+
return String(text ?? '').replace(/\s+/g, ' ').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** 去重键:忽略**空格与中英标点差异**(中文输入法下 ,/,、./。 常混用)。
|
|
30
|
+
* 1.5.2 起对外导出:渲染出口(persona.js)复用同一把尺,禁止另发明第二套归一化。 */
|
|
31
|
+
export function key(text) {
|
|
32
|
+
return norm(text)
|
|
33
|
+
.replace(/[,。;:!?、()【】“”‘’《》]/g, '') // 全角标点整体去掉
|
|
34
|
+
.replace(/[\s,.!?;:()[\]{}"'`~\-—…·]/g, ''); // 半角标点与空白
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 规矩正文列表(过滤空值)。 */
|
|
38
|
+
export function rulesOf(settings) {
|
|
39
|
+
const p = settings?.get ? settings.get().persona || {} : settings?.persona || {};
|
|
40
|
+
return (Array.isArray(p.hardRules) ? p.hardRules : []).map(norm).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 规矩来源留痕(数组:对象存储会被 settings 的深合并"只增不删",删除时清不掉)。 */
|
|
44
|
+
export function ruleMetaOf(settings) {
|
|
45
|
+
const p = settings?.get ? settings.get().persona || {} : settings?.persona || {};
|
|
46
|
+
return (Array.isArray(p.ruleMeta) ? p.ruleMeta : []).filter((m) => m && typeof m.text === 'string');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 已定习惯。 */
|
|
50
|
+
export function habitsOf(settings) {
|
|
51
|
+
const p = settings?.get ? settings.get().persona || {} : settings?.persona || {};
|
|
52
|
+
return (Array.isArray(p.habits) ? p.habits : []).filter((h) => h && norm(h.text));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 待确认的习惯提议。 */
|
|
56
|
+
export function habitsPendingOf(settings) {
|
|
57
|
+
const p = settings?.get ? settings.get().persona || {} : settings?.persona || {};
|
|
58
|
+
return (Array.isArray(p.habitsPending) ? p.habitsPending : []).filter((h) => h && norm(h.text));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** 待确认队列上限(可由 settings.habits.pendingMax 覆盖)。 */
|
|
62
|
+
export function pendingMaxOf(settings) {
|
|
63
|
+
const root = settings?.get ? settings.get() : (settings || {});
|
|
64
|
+
const n = Number(root?.habits?.pendingMax);
|
|
65
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : PENDING_MAX_DEFAULT;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 汇总视图(面板/接口用)。 */
|
|
69
|
+
export function rulesView(settings) {
|
|
70
|
+
const meta = ruleMetaOf(settings);
|
|
71
|
+
return {
|
|
72
|
+
rules: rulesOf(settings).map((text) => ({ text, ...(ruleMetaOf(settings).find((m) => m.text === text) || {}) })),
|
|
73
|
+
habits: habitsOf(settings),
|
|
74
|
+
pending: habitsPendingOf(settings),
|
|
75
|
+
caps: { rules: RULES_MAX, habits: HABITS_MAX, pending: pendingMaxOf(settings) },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 写入后统一收尾:落库 + 交给调用方做快照失效。返回最新 settings 快照。 */
|
|
80
|
+
async function persist(settings, personaPatch) {
|
|
81
|
+
return settings.update({ persona: personaPatch });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 追加一条规矩。
|
|
86
|
+
* - `source:'session'`(默认,器灵在会话里代写):**必须带原话** —— 没有原话写不进去,「经用户同意」可核查;
|
|
87
|
+
* - `source:'panel'`(用户本人在面板里写):他自己敲的就是同意,不再要求另附原话,留痕记「面板直接写入」。
|
|
88
|
+
* @returns {{ok:boolean, reason?:string, text?:string, warning?:string, rules?:string[]}}
|
|
89
|
+
*/
|
|
90
|
+
export async function addRule({ settings, rule, quote, sessionId = '', now = Date.now(), source = 'session' }) {
|
|
91
|
+
const text = norm(rule);
|
|
92
|
+
const fromPanel = source === 'panel';
|
|
93
|
+
const src = fromPanel ? '(面板直接写入)' : norm(quote);
|
|
94
|
+
if (!text) return { ok: false, reason: 'empty-rule' };
|
|
95
|
+
if (!src) return { ok: false, reason: 'no-quote' };
|
|
96
|
+
if (text.length > RULE_MAX_CHARS) return { ok: false, reason: 'too-long', limit: RULE_MAX_CHARS };
|
|
97
|
+
if (src.length > QUOTE_MAX_CHARS) return { ok: false, reason: 'quote-too-long', limit: QUOTE_MAX_CHARS };
|
|
98
|
+
|
|
99
|
+
const current = rulesOf(settings);
|
|
100
|
+
const exists = current.find((r) => key(r) === key(text));
|
|
101
|
+
if (exists) {
|
|
102
|
+
const hit = ruleMetaOf(settings).find((m) => m.text === exists) || null;
|
|
103
|
+
return { ok: false, reason: 'duplicate', text: exists, meta: hit };
|
|
104
|
+
}
|
|
105
|
+
const next = [...current, text];
|
|
106
|
+
const meta = [...ruleMetaOf(settings), { text, quote: src, at: now, sessionId: String(sessionId || ''), source: fromPanel ? 'panel' : 'session' }];
|
|
107
|
+
const warning = next.length > RULES_MAX ? 'over-cap' : undefined;
|
|
108
|
+
const s2 = await persist(settings, { hardRules: next, ruleMeta: meta });
|
|
109
|
+
return { ok: true, text, warning, rules: rulesOf({ persona: s2.persona }) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 删除一条规矩(只按正文匹配;调用方负责留痕)。 */
|
|
113
|
+
export async function removeRule({ settings, rule }) {
|
|
114
|
+
const text = norm(rule);
|
|
115
|
+
const current = rulesOf(settings);
|
|
116
|
+
const hit = current.find((r) => key(r) === key(text));
|
|
117
|
+
if (!hit) return { ok: false, reason: 'not-found' };
|
|
118
|
+
const next = current.filter((r) => r !== hit);
|
|
119
|
+
const meta = ruleMetaOf(settings).filter((m) => m.text !== hit);
|
|
120
|
+
await persist(settings, { hardRules: next, ruleMeta: meta });
|
|
121
|
+
return { ok: true, text: hit, rules: next };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** 提议一条习惯(任一方都可提议;落地必须经对方确认)。 */
|
|
125
|
+
export async function proposeHabit({ settings, habit, evidence = '', byUser = false, now = Date.now() }) {
|
|
126
|
+
const text = norm(habit);
|
|
127
|
+
const ev = norm(evidence).slice(0, EVIDENCE_MAX_CHARS);
|
|
128
|
+
if (!text) return { ok: false, reason: 'empty-habit' };
|
|
129
|
+
if (text.length > RULE_MAX_CHARS) return { ok: false, reason: 'too-long', limit: RULE_MAX_CHARS };
|
|
130
|
+
if (habitsOf(settings).some((h) => key(h.text) === key(text))) return { ok: false, reason: 'already-habit' };
|
|
131
|
+
const pending = habitsPendingOf(settings);
|
|
132
|
+
const dup = pending.find((h) => key(h.text) === key(text));
|
|
133
|
+
if (dup) return { ok: false, reason: 'already-pending', id: dup.id };
|
|
134
|
+
// 上限:满了就驳回,不再往里塞(2026-09-16 定案)—— 让她"等着"才是最伤人的那种安排。
|
|
135
|
+
const cap = pendingMaxOf(settings);
|
|
136
|
+
if (pending.length >= cap) return { ok: false, reason: 'pending-full', limit: cap, pending: pending.length };
|
|
137
|
+
|
|
138
|
+
const id = `h${now.toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
139
|
+
const next = [...pending, { id, text, evidence: ev, byUser: !!byUser, at: now }];
|
|
140
|
+
await persist(settings, { habitsPending: next });
|
|
141
|
+
return { ok: true, id, pending: next.length, text };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** 处理一条习惯提议:确认则落地为习惯,驳回则丢弃。 */
|
|
145
|
+
export async function resolveHabit({ settings, id, action, now = Date.now() }) {
|
|
146
|
+
const pending = habitsPendingOf(settings);
|
|
147
|
+
const item = pending.find((h) => h.id === String(id));
|
|
148
|
+
if (!item) return { ok: false, reason: 'not-found' };
|
|
149
|
+
const rest = pending.filter((h) => h.id !== item.id);
|
|
150
|
+
if (action === 'reject') {
|
|
151
|
+
await persist(settings, { habitsPending: rest });
|
|
152
|
+
return { ok: true, rejected: item.text, pending: rest.length };
|
|
153
|
+
}
|
|
154
|
+
if (action !== 'confirm') return { ok: false, reason: 'bad-action' };
|
|
155
|
+
|
|
156
|
+
// 禁止重复落地:确认时若已存在同名习惯,只清 pending。
|
|
157
|
+
const existing = habitsOf(settings);
|
|
158
|
+
if (existing.some((h) => key(h.text) === key(item.text))) {
|
|
159
|
+
await persist(settings, { habitsPending: rest });
|
|
160
|
+
return { ok: false, reason: 'already-habit', text: item.text };
|
|
161
|
+
}
|
|
162
|
+
const next = [...existing, { text: item.text, evidence: item.evidence || '', at: now, source: item.byUser ? 'user-proposed' : 'grown' }];
|
|
163
|
+
const warning = next.length > HABITS_MAX ? 'over-cap' : undefined;
|
|
164
|
+
await persist(settings, { habitsPending: rest, habits: next });
|
|
165
|
+
return { ok: true, text: item.text, warning, habits: next.map((h) => h.text) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 器灵对「用户提议的习惯」表态:改一个说法(amend)—— 改完仍留在待确认里,等他点头。
|
|
170
|
+
* 语义(2026-09-16 定稿):他提议 → 我回应;我若改法,改后的文本回到他那边确认。
|
|
171
|
+
*/
|
|
172
|
+
export async function amendHabit({ settings, id, text, note = '', now = Date.now() }) {
|
|
173
|
+
const pending = habitsPendingOf(settings);
|
|
174
|
+
const item = pending.find((h) => h.id === String(id));
|
|
175
|
+
if (!item) return { ok: false, reason: 'not-found' };
|
|
176
|
+
const next = norm(text);
|
|
177
|
+
if (!next) return { ok: false, reason: 'empty-habit' };
|
|
178
|
+
if (next.length > RULE_MAX_CHARS) return { ok: false, reason: 'too-long', limit: RULE_MAX_CHARS };
|
|
179
|
+
const list = pending.map((h) => (h.id === item.id
|
|
180
|
+
? { ...h, text: next, amendedBy: 'ling', amendNote: norm(note).slice(0, 200), amendedAt: now }
|
|
181
|
+
: h));
|
|
182
|
+
await persist(settings, { habitsPending: list });
|
|
183
|
+
return { ok: true, id: item.id, from: item.text, text: next };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** 删除一条已定习惯(同样留痕由调用方负责)。 */
|
|
187
|
+
export async function removeHabit({ settings, habit }) {
|
|
188
|
+
const text = norm(habit);
|
|
189
|
+
const current = habitsOf(settings);
|
|
190
|
+
const hit = current.find((h) => key(h.text) === key(text));
|
|
191
|
+
if (!hit) return { ok: false, reason: 'not-found' };
|
|
192
|
+
const next = current.filter((h) => h !== hit);
|
|
193
|
+
await persist(settings, { habits: next });
|
|
194
|
+
return { ok: true, text, habits: next.map((h) => h.text) };
|
|
195
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// dsh-ling host — 网页端(ds-search)库增量扫描 → 记忆库 conv_overview。
|
|
2
|
+
// 数据源形态与 tools/import-dsweb.mjs 一致:ds-search 生成的 deepseek_library.db
|
|
3
|
+
// (conversations / messages 两张表)。本模块供 API 层与工具共用,幂等(按 conv_id
|
|
4
|
+
// upsert 元数据,不动本地 summary/importance/置顶等用户态字段)。
|
|
5
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
|
|
8
|
+
// ---------- 类别启发式(与 v0 工具同源,保证一致性) ----------
|
|
9
|
+
const KNOW_PAT = /物理|模拟|材料|合金|金属|固溶|热处理|退火|淬火|SLM|激光|3D|打印|工艺|实验|力学|运动|弹簧|电|磁|机械|轴承|齿轮|螺栓|公差|图纸|仿真|计算|推导|方程|函数|算法|代码|编程|程序|开发|设计|架构|编辑器|工具|软件|系统|虚拟机|显示|显卡|显示器|网络|协议|数据|数据库|安全|加密|AI|GPT|LLM|模型|HTML|CSS|插件|开源|文献|论文|综述|研究|分析|优化|参数|标准|工程|仿真|python|前端|后端|电路|噪声|声|光|波|流体|温度|硬度|刚度|强度|疲劳|失效|锈|腐蚀|焊接|铸造|锻|粉末|显微|扫描|衍射|晶体|薄膜|电池|半导体|传感器|嵌入式|单片机|遥控|信号|频|放大器|扬声器|摄像头|无人|遥控器|传感器|直线|滚珠|丝杆|导轨|减速|电机|驱动|单片机|stm|arduino|树莓派|统计|概率|几何|代数|微积分|复变|信号与系统|傅里叶|拉普拉斯|微分方程|数值|离散|连续|随机/;
|
|
10
|
+
const FEEL_PAT = /失眠|睡眠|睡不着|焦虑|烦躁|抑郁|emo|情绪|心情|感情|关系|朋友|家人|父母|孤独|迷茫|人生|困惑|害怕|恐惧|压力|疲惫|累|休息|放松|静心|呼吸|梦境|梦|倾诉|共鸣|温暖|鼓励|安慰|不安|内耗|自我|意义|幸福|遗憾|夜|月亮|散文|随想|杂感|闲聊|谈心|牢骚|吐槽|习惯|拖延|自律|动力|目标感|虚无|发呆|感性|告别|怀念|感谢|感动|爱|喜欢(一个人)?/;
|
|
11
|
+
|
|
12
|
+
const CJK_SEG = /[\u4e00-\u9fff]{2,}/g;
|
|
13
|
+
|
|
14
|
+
export function keywordsFromText(text, max = 14) {
|
|
15
|
+
if (!text) return [];
|
|
16
|
+
const out = [];
|
|
17
|
+
const segs = String(text).match(CJK_SEG) || [];
|
|
18
|
+
for (const seg of segs.slice(0, 8)) {
|
|
19
|
+
const step = Math.min(6, seg.length);
|
|
20
|
+
for (let i = 0; i + step <= seg.length && out.length < max; i += step) {
|
|
21
|
+
const kw = seg.slice(i, i + step);
|
|
22
|
+
if (!out.includes(kw)) out.push(kw);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return out.slice(0, max);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function categorize(title) {
|
|
29
|
+
if (FEEL_PAT.test(String(title))) return 'feeling';
|
|
30
|
+
if (KNOW_PAT.test(String(title))) return 'knowledge';
|
|
31
|
+
return 'daily';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const CAT_NAME = { knowledge: '知识', feeling: '生活', daily: '日常' };
|
|
35
|
+
|
|
36
|
+
/** 读源库 → overview 行(不写库)。返回 { sourceTotal, rows, skipped }。 */
|
|
37
|
+
export function readDswebRows(dbPath, { limit = 0, since = '' } = {}) {
|
|
38
|
+
if (!dbPath || !existsSync(String(dbPath))) {
|
|
39
|
+
const e = new Error('数据源不存在:' + dbPath);
|
|
40
|
+
e.code = 'ENOENT_SRC';
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
const src = new DatabaseSync(String(dbPath), { readOnly: true });
|
|
44
|
+
try {
|
|
45
|
+
const convRows = src.prepare(
|
|
46
|
+
'SELECT conv_id, title, inserted_at, updated_at, n_user FROM conversations ORDER BY idx',
|
|
47
|
+
).all();
|
|
48
|
+
const userMsg = src.prepare(
|
|
49
|
+
"SELECT conv_id, text FROM messages WHERE role='USER' AND conv_id=? AND text IS NOT NULL AND length(text)>0 ORDER BY seq LIMIT 1",
|
|
50
|
+
);
|
|
51
|
+
const firstUserText = new Map();
|
|
52
|
+
for (const c of convRows) {
|
|
53
|
+
const r = userMsg.get(c.conv_id);
|
|
54
|
+
if (r && r.text) firstUserText.set(c.conv_id, r.text);
|
|
55
|
+
}
|
|
56
|
+
const rows = [];
|
|
57
|
+
for (const c of convRows) {
|
|
58
|
+
const title = String(c.title || '').trim();
|
|
59
|
+
if (!title) continue;
|
|
60
|
+
const inserted = c.inserted_at ? String(c.inserted_at) : '';
|
|
61
|
+
if (since && inserted.slice(0, 10) < since) continue;
|
|
62
|
+
const first = firstUserText.get(c.conv_id) || title;
|
|
63
|
+
const category = categorize(title);
|
|
64
|
+
rows.push({
|
|
65
|
+
conv_id: String(c.conv_id),
|
|
66
|
+
source: 'dsweb',
|
|
67
|
+
title,
|
|
68
|
+
started_at: inserted,
|
|
69
|
+
updated_at: c.updated_at ? String(c.updated_at) : inserted,
|
|
70
|
+
domain_tags: [CAT_NAME[category] || '日常'],
|
|
71
|
+
category,
|
|
72
|
+
keywords: keywordsFromText((title + ' ' + first).slice(0, 300)),
|
|
73
|
+
summary: '',
|
|
74
|
+
heat: 0,
|
|
75
|
+
importance: 0,
|
|
76
|
+
last_hit_at: null,
|
|
77
|
+
hit_count: 0,
|
|
78
|
+
overview_ok: 1,
|
|
79
|
+
origin: 'dsweb-import-v0',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (limit > 0) rows.length = Math.min(rows.length, limit);
|
|
83
|
+
return { sourceTotal: convRows.length, rows };
|
|
84
|
+
} finally {
|
|
85
|
+
src.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 增量入库:已存在 = 仅刷新元数据(updated/title/category/keywords);不动 summary/importance/置顶。 */
|
|
90
|
+
export function scanIntoMemory(memory, dbPath, { limit = 0, since = '' } = {}) {
|
|
91
|
+
const { sourceTotal, rows } = readDswebRows(dbPath, { limit, since });
|
|
92
|
+
const existing = new Set(
|
|
93
|
+
memory.db.prepare("SELECT conv_id FROM conv_overview WHERE source='dsweb'").all().map((r) => String(r.conv_id)),
|
|
94
|
+
);
|
|
95
|
+
let added = 0;
|
|
96
|
+
let refreshed = 0;
|
|
97
|
+
for (const r of rows) {
|
|
98
|
+
if (existing.has(r.conv_id)) {
|
|
99
|
+
memory.db.prepare(
|
|
100
|
+
`UPDATE conv_overview SET title=?, updated_at=?, domain_tags=?, category=?, keywords=?,
|
|
101
|
+
overview_ok=1, origin=? WHERE source='dsweb' AND conv_id=?`,
|
|
102
|
+
).run(r.title, r.updated_at || null, JSON.stringify(r.domain_tags), r.category, JSON.stringify(r.keywords), r.origin, r.conv_id);
|
|
103
|
+
refreshed++;
|
|
104
|
+
} else {
|
|
105
|
+
memory.upsertOverview(r);
|
|
106
|
+
added++;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const total = memory.db.prepare("SELECT COUNT(*) n FROM conv_overview WHERE source='dsweb'").get().n;
|
|
110
|
+
return { sourceTotal, seen: rows.length, added, refreshed, total: Number(total), skipped: sourceTotal - rows.length };
|
|
111
|
+
}
|
package/lib/host/seal.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// dsh-ling host — 定型锁承诺句(纯函数,无平台依赖,可单测)。
|
|
2
|
+
// 语义(2026-09-07 用户拍板):锁不防读取,只造庄重。
|
|
3
|
+
// - 「钥匙」= 上锁时用户亲手写下的一句有意义的承诺(≥10 字);
|
|
4
|
+
// - 明文存储(sealPhrase),解锁界面会回显这句,永不因遗忘卡死;
|
|
5
|
+
// - 庄重感来源:解锁/保存/撤锁必须亲手把这句话敲一遍(输入框禁粘贴);
|
|
6
|
+
// - 上锁(sealed false→true)必须带新承诺句 → 覆盖旧句;
|
|
7
|
+
// - 兼容迁移:sealed=true 但 sealPhrase 为空(旧哈希时代数据)→ 解锁框输入的首句即被采纳。
|
|
8
|
+
export const KEY_MIN = 10;
|
|
9
|
+
|
|
10
|
+
export function sealOk(p, unlock) {
|
|
11
|
+
// 返回 { pass:boolean, adoptKey?:string|null } — adoptKey 非空表示这句将被采纳存储
|
|
12
|
+
const v = typeof unlock === 'string' ? unlock.trim() : '';
|
|
13
|
+
if (!p || p.sealed !== true) return { pass: true, adoptKey: null };
|
|
14
|
+
if (!v || v.length < KEY_MIN) return { pass: false, adoptKey: null };
|
|
15
|
+
if (!p.sealPhrase) return { pass: true, adoptKey: v }; // 旧档案无明文:首句即设钥
|
|
16
|
+
return { pass: p.sealPhrase === v, adoptKey: null };
|
|
17
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// dsh-ling host — settings persistence v2.
|
|
2
|
+
// Persists ONLY user patches (decoupled from code defaults so future default
|
|
3
|
+
// changes propagate); one-time migration from the v0 full-snapshot file keeps
|
|
4
|
+
// only the user-meaningful { mode.lastMode }.
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ensureDir, mergeDeep, readJsonSafe, writeJsonAtomic } from './util.js';
|
|
7
|
+
import { DEFAULT_SETTINGS } from './persona.js';
|
|
8
|
+
|
|
9
|
+
const SETTINGS_VERSION = 2;
|
|
10
|
+
|
|
11
|
+
export class SettingsFile {
|
|
12
|
+
constructor(dataDir) {
|
|
13
|
+
this.dir = ensureDir(dataDir);
|
|
14
|
+
this.file = join(dataDir, 'settings.json');
|
|
15
|
+
const stored = readJsonSafe(this.file, {});
|
|
16
|
+
let user;
|
|
17
|
+
if (stored && stored.__v === SETTINGS_VERSION && stored.user) {
|
|
18
|
+
user = mergeDeep({}, stored.user);
|
|
19
|
+
} else if (stored && stored.__v === SETTINGS_VERSION) {
|
|
20
|
+
user = mergeDeep({}, stored);
|
|
21
|
+
} else {
|
|
22
|
+
// v0/v1 迁移:旧文件是"默认值全量快照",只保留用户关心的 lastMode
|
|
23
|
+
user = {};
|
|
24
|
+
const lm = stored?.mode?.lastMode;
|
|
25
|
+
if (lm === 'work' || lm === 'life') user = { mode: { lastMode: lm } };
|
|
26
|
+
}
|
|
27
|
+
this.user = user || {};
|
|
28
|
+
this.current = mergeDeep(structuredClone(DEFAULT_SETTINGS), this.user);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
get() {
|
|
32
|
+
return this.current;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Apply a user patch (deep-merged onto current defaults); persist patch only. */
|
|
36
|
+
async update(patch) {
|
|
37
|
+
this.user = mergeDeep(this.user, patch || {});
|
|
38
|
+
this.current = mergeDeep(structuredClone(DEFAULT_SETTINGS), this.user);
|
|
39
|
+
await writeJsonAtomic(this.file, { __v: SETTINGS_VERSION, user: this.user });
|
|
40
|
+
return this.current;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async reset() {
|
|
44
|
+
this.user = {};
|
|
45
|
+
this.current = structuredClone(DEFAULT_SETTINGS);
|
|
46
|
+
await writeJsonAtomic(this.file, { __v: SETTINGS_VERSION, user: {} });
|
|
47
|
+
return this.current;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// dsh-ling host — per-session memory snapshot builder (DESIGN §4.3).
|
|
2
|
+
// A snapshot = stable text frozen per session for KV-cache stability:
|
|
3
|
+
// L0 persona (identity + mode style) + L1 opener selection (mode weighted).
|
|
4
|
+
// Also hosts message-text helpers for the durable event record shape:
|
|
5
|
+
// { type, seq, time(ms), data: { content?|message:{content}, source? } }
|
|
6
|
+
import { assemblePersona } from './persona.js';
|
|
7
|
+
import { selectL1, formatL1Section } from './l1.js';
|
|
8
|
+
import { currentMode } from './mode.js';
|
|
9
|
+
import { neutralizeMustache } from './util.js';
|
|
10
|
+
|
|
11
|
+
export function buildSnapshotText(ctx, memory, settings, sessionId, { keywords = [], now = Date.now(), bumpHits = false } = {}) {
|
|
12
|
+
const s = settings.get();
|
|
13
|
+
const personaEnabled = s.persona?.enabled !== false;
|
|
14
|
+
const mode = currentMode(memory, settings, sessionId);
|
|
15
|
+
const parts = [];
|
|
16
|
+
if (personaEnabled) {
|
|
17
|
+
const l0 = assemblePersona(s, mode);
|
|
18
|
+
if (l0) parts.push(l0);
|
|
19
|
+
}
|
|
20
|
+
if (personaEnabled && s.memory?.l1Enabled !== false && memory.listOverviews({ onlyOk: true }).length > 0) {
|
|
21
|
+
const cfg = s.memory || {};
|
|
22
|
+
const result = selectL1(memory, {
|
|
23
|
+
mode,
|
|
24
|
+
keywords: keywords || [],
|
|
25
|
+
sessionId, // D9-a 血缘加权:当前会话所属枝决定各枝记忆的档位(同枝 1.0/祖先 0.7/旁系 0.4)
|
|
26
|
+
categoryWeights: cfg.categoryWeights,
|
|
27
|
+
maxItems: cfg.l1MaxItems,
|
|
28
|
+
maxLineChars: cfg.l1MaxLineChars,
|
|
29
|
+
summaryChars: cfg.l1SummaryChars,
|
|
30
|
+
summaryBonus: cfg.summaryBonus,
|
|
31
|
+
budgetChars: Math.max(200, Math.round((cfg.l1BudgetTokens ?? 1200) * 1.4)), // 中文≈1.4字/token 保守
|
|
32
|
+
});
|
|
33
|
+
// 命中记录(2026-09-16):只有**首次定稿**传 bumpHits,重建/空闲刷新不重复计 ——
|
|
34
|
+
// 否则《会话变长 → 反复刷新》会把同一批记忆的 hit_count 刷高,把热度算歪。
|
|
35
|
+
if (bumpHits && Array.isArray(result.items) && result.items.length) {
|
|
36
|
+
try {
|
|
37
|
+
for (const it of result.items) memory.bumpHit(it.source, it.conv_id);
|
|
38
|
+
} catch { /* 记不上不影响注入 */ }
|
|
39
|
+
}
|
|
40
|
+
const l1 = formatL1Section(result);
|
|
41
|
+
if (l1) parts.push(l1);
|
|
42
|
+
}
|
|
43
|
+
// 注入感知(D9-b,2026-09-22):主人对记忆树做的**结构改动**,让器灵在下一次开口前就看到
|
|
44
|
+
// "树变了" —— 这是复盘时"互相纠正"能成立的前提(不需要推送:复盘对话密集,
|
|
45
|
+
// 每说一句就注入一次 ⇒ 感知延迟只有一轮,体感即实时)。
|
|
46
|
+
// 只报**没见过的**改动(水位 = branch_log 自增 id),否则每轮重复注入同一批。
|
|
47
|
+
// B#9(2026-09-22 审计):**渲染不消费水位** —— 这里只读、不写。
|
|
48
|
+
// buildSnapshotText 也会被"只重建、未开口"的路径调用(invalidateSession / refreshIdleSnapshot),
|
|
49
|
+
// 在那里推进水位会把主人刚改的东西标记成已读,等到真正注入时反而看不到。
|
|
50
|
+
// 水位按**会话**存(`branch_log_wm.<sessionId>`),推进由 inject.js 在真正要开口时做。
|
|
51
|
+
let logMaxId = 0;
|
|
52
|
+
try {
|
|
53
|
+
if (typeof memory.branchLogDigest === 'function') {
|
|
54
|
+
const wmk = 'branch_log_wm.' + String(sessionId || '');
|
|
55
|
+
const prev = Number(memory.kvGet(wmk) || 0);
|
|
56
|
+
const d = memory.branchLogDigest({ limit: 5, afterId: prev });
|
|
57
|
+
if (d && Array.isArray(d.lines) && d.lines.length) {
|
|
58
|
+
// A-03 / D-S03(1.5.1 红蓝对抗):段首此前**写死**「主人刚改动了记忆树」,而 schema v12 起
|
|
59
|
+
// 每一行都已带主语(`branchLogDigest` 按 actor 分流成「主人」/「器灵」)⇒ 器灵经工具
|
|
60
|
+
// 改的树,在它**下一轮的系统提示**里被读成"主人刚改动",与 tools.js 的设计意图正相反。
|
|
61
|
+
// 段首改中性措辞,**主语交给每一行** —— 谁改的由行内说,不由段头替它说。
|
|
62
|
+
parts.push(['【记忆树有改动】', ...d.lines.map((x) => '- ' + x)].join('\n'));
|
|
63
|
+
logMaxId = Number(d.maxId) || 0;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch { /* 感知失败不影响注入 */ }
|
|
67
|
+
// 注入面出口 ①(快照侧,2026-09-18):L0 人格 + L1 记忆在这里成文。宿主 renderPrompt
|
|
68
|
+
// 无 try/catch,未注册的 `{{名字}}` 会让该会话每一步都失败 ⇒ 成文即中和(幂等)。
|
|
69
|
+
// 时间锚不经此处,由 inject.js 的最终出口兜底。
|
|
70
|
+
const text = neutralizeMustache(parts.join('\n\n'));
|
|
71
|
+
return {
|
|
72
|
+
text,
|
|
73
|
+
mode,
|
|
74
|
+
// B#9:本段文本渲染到了哪条 branch_log(inject.js 在真正注入时据此推进水位)
|
|
75
|
+
logMaxId,
|
|
76
|
+
builtAt: new Date(now).toISOString(),
|
|
77
|
+
personaVersion: snapshotVersion(s),
|
|
78
|
+
memoryVersion: memory.kvGet('memory_version') || '0',
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function snapshotVersion(s) {
|
|
83
|
+
const p = s?.persona || {};
|
|
84
|
+
return [p.userTitle, p.aiName, p.aiTitle, p.tone, p.toneWork, p.toneLife, p.language, p.extraLore, p.enabled, JSON.stringify(p.hardRules), JSON.stringify(p.bottomLines), p.sealed, s?.mode?.lastMode]
|
|
85
|
+
.join('|');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** source.kind of a durable message event (user/plugin/skill-catalog/goal/…) */
|
|
89
|
+
export function messageSourceKind(event) {
|
|
90
|
+
const d = event?.data ?? event?.payload ?? event;
|
|
91
|
+
if (d?.source && typeof d.source === 'object') return d.source.kind ?? null;
|
|
92
|
+
if (d?.sourceKind) return d.sourceKind;
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SKIP_BLOCK_TYPES = new Set(['reasoning', 'tool-call', 'tool-result', 'refusal', 'image', 'file']);
|
|
97
|
+
|
|
98
|
+
function blockText(b) {
|
|
99
|
+
if (typeof b === 'string') return b;
|
|
100
|
+
if (!b || typeof b !== 'object') return '';
|
|
101
|
+
const t = typeof b.text === 'string' ? b.text : typeof b.content === 'string' ? b.content : '';
|
|
102
|
+
return t || '';
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function blocksToText(blocks) {
|
|
106
|
+
if (typeof blocks === 'string') return blocks;
|
|
107
|
+
if (!Array.isArray(blocks)) return '';
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const b of blocks) {
|
|
110
|
+
if (b && typeof b === 'object' && SKIP_BLOCK_TYPES.has(b.type)) continue;
|
|
111
|
+
const t = blockText(b);
|
|
112
|
+
if (t) out.push(t);
|
|
113
|
+
}
|
|
114
|
+
return out.join('\n');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Visible text of a durable user/assistant message event (real record shape). */
|
|
118
|
+
export function extractMessageText(event) {
|
|
119
|
+
const d = event?.data ?? event?.payload ?? event;
|
|
120
|
+
if (!d || typeof d !== 'object') return '';
|
|
121
|
+
if (typeof d.content === 'string') return d.content;
|
|
122
|
+
if (Array.isArray(d.content)) return blocksToText(d.content);
|
|
123
|
+
const m = d?.message;
|
|
124
|
+
if (m && typeof m === 'object') {
|
|
125
|
+
if (typeof m.content === 'string') return m.content;
|
|
126
|
+
if (Array.isArray(m.content)) return blocksToText(m.content);
|
|
127
|
+
}
|
|
128
|
+
return extractText(event);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 真人首条消息(排除 plugin/skill-catalog 注入),供 L1 关键词。 */
|
|
132
|
+
export function firstUserText(agent) {
|
|
133
|
+
try {
|
|
134
|
+
const session = agent?.session;
|
|
135
|
+
if (!session || typeof session.snapshotEvents !== 'function') return '';
|
|
136
|
+
const events = session.snapshotEvents();
|
|
137
|
+
for (const ev of events || []) {
|
|
138
|
+
if (ev?.type === 'user/message' && messageSourceKind(ev) === 'user') {
|
|
139
|
+
return extractMessageText(ev);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
return '';
|
|
144
|
+
}
|
|
145
|
+
return '';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Legacy generic extractor (first text/content found anywhere). */
|
|
149
|
+
export function extractText(event) {
|
|
150
|
+
const p = event?.payload || event;
|
|
151
|
+
const msg = p?.message && typeof p.message === 'object' ? p.message : null;
|
|
152
|
+
const content = p?.content ?? p?.text ?? (msg ? (msg.content ?? msg.text) : undefined);
|
|
153
|
+
return contentOf(content);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function contentOf(v) {
|
|
157
|
+
if (typeof v === 'string') return v;
|
|
158
|
+
if (Array.isArray(v)) {
|
|
159
|
+
return v.map((b) => contentOf(b)).filter(Boolean).join('\n');
|
|
160
|
+
}
|
|
161
|
+
if (v && typeof v === 'object') {
|
|
162
|
+
for (const k of ['text', 'content']) {
|
|
163
|
+
const c = contentOf(v[k]);
|
|
164
|
+
if (c) return c;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return '';
|
|
168
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// dsh-ling host — 语料建议的采纳/忽略(人审;kind 决定写入哪个 persona 字段)
|
|
2
|
+
// 2026-09-15 起:hardrule 类建议是**她长出来的**东西 → 走「提议 → 确认」落到**习惯**(不再是规矩)。
|
|
3
|
+
import { proposeHabit, resolveHabit, habitsOf } from './rules.js';
|
|
4
|
+
|
|
5
|
+
const KIND_NAMES = { aiName: '自称', userTitle: '称呼', hardrule: '习惯' };
|
|
6
|
+
|
|
7
|
+
export async function applyCorpusSuggestion(memory, settings, id) {
|
|
8
|
+
const row = memory.getPersonaSuggestion(Number(id));
|
|
9
|
+
if (!row) return { ok: false, reason: 'not-found' };
|
|
10
|
+
if (row.status !== 'new') return { ok: false, reason: 'status:' + row.status };
|
|
11
|
+
const value = String(row.value ?? '').trim();
|
|
12
|
+
const patch = {};
|
|
13
|
+
if (row.kind === 'aiName') {
|
|
14
|
+
if (!value) return { ok: false, reason: 'empty-value' };
|
|
15
|
+
patch.persona = { aiName: value };
|
|
16
|
+
} else if (row.kind === 'userTitle') {
|
|
17
|
+
if (!value) return { ok: false, reason: 'empty-value' };
|
|
18
|
+
patch.persona = { userTitle: value };
|
|
19
|
+
} else if (row.kind === 'hardrule') {
|
|
20
|
+
if (!value) return { ok: false, reason: 'empty-value' };
|
|
21
|
+
// 面板上按下"采纳"= 确认动作 → 提议后立即确认(习惯不可直达,但仍只能走这条状态机)
|
|
22
|
+
const prop = await proposeHabit({ settings, habit: value, evidence: '语料提炼(面板采纳即确认)', byUser: true });
|
|
23
|
+
if (prop.ok) {
|
|
24
|
+
await resolveHabit({ settings, id: prop.id, action: 'confirm' });
|
|
25
|
+
} else if (prop.reason !== 'already-habit' && prop.reason !== 'already-pending') {
|
|
26
|
+
return { ok: false, reason: prop.reason };
|
|
27
|
+
}
|
|
28
|
+
patch.persona = {};
|
|
29
|
+
} else {
|
|
30
|
+
return { ok: false, reason: 'unknown-kind:' + row.kind };
|
|
31
|
+
}
|
|
32
|
+
await settings.update(patch);
|
|
33
|
+
memory.setPersonaSuggestionStatus(Number(id), 'adopted');
|
|
34
|
+
return { ok: true, id: Number(id), kind: row.kind, kindName: KIND_NAMES[row.kind] || row.kind, value, patch, habits: habitsOf(settings).length };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function dismissCorpusSuggestion(memory, id) {
|
|
38
|
+
const row = memory.getPersonaSuggestion(Number(id));
|
|
39
|
+
if (!row) return { ok: false, reason: 'not-found' };
|
|
40
|
+
memory.setPersonaSuggestionStatus(Number(id), 'dismissed');
|
|
41
|
+
return { ok: true, id: Number(id) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function suggestionDisplay(row) {
|
|
45
|
+
const kindName = KIND_NAMES[row?.kind] || row?.kind || '?';
|
|
46
|
+
return { ...row, kindName };
|
|
47
|
+
}
|