@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,338 @@
|
|
|
1
|
+
// dsh-ling host — LLM 深摘要(循环外一次性调用 ctx.llm.stream;通道经 2026-09-07 真机验证)。
|
|
2
|
+
// 原文双源:DB(dsh_turns_raw)+ 磁盘(~/.dsh/sessions,存量会话)。探针严格:空回复判失败。
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { svc, utcIso, dshHome } from './util.js';
|
|
7
|
+
import { RAW_IMPORT_PREFIX, rawSessionCandidates } from './memory.js';
|
|
8
|
+
import { pickSessionFile } from './backfill.js';
|
|
9
|
+
import { currentModelSelection } from './mode.js';
|
|
10
|
+
import { decodeAll } from './zstd.js';
|
|
11
|
+
|
|
12
|
+
// 循环外调用(深摘要 / 诞生仪式 / 语气归纳 / 自述起草)一律**跟随用户当前的模型选择**:
|
|
13
|
+
// 平台 API 要求显式给出 {provider, model},故读 agentDefaultModel.currentSelection();
|
|
14
|
+
// 只有读不到时才退回下面的兜底值(是兜底,不是"替用户指定模型")。
|
|
15
|
+
const FALLBACK_PROVIDER = 'deepseek-official';
|
|
16
|
+
const FALLBACK_MODEL = 'deepseek-v4-flash';
|
|
17
|
+
|
|
18
|
+
export const DEEP_CFG = {
|
|
19
|
+
fallbackProvider: FALLBACK_PROVIDER,
|
|
20
|
+
fallbackModel: FALLBACK_MODEL,
|
|
21
|
+
effort: 'low', // 循环外只做摘要/归纳,不需要高推理等级(与模式档位无关)
|
|
22
|
+
maxTokens: 900,
|
|
23
|
+
temperature: 0.2,
|
|
24
|
+
inputCap: 26000,
|
|
25
|
+
diskMinBytes: 64 * 1024,
|
|
26
|
+
autoIntervalMin: 30,
|
|
27
|
+
perRunLimit: 2,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** 解析循环外调用使用的模型:跟随当前选择;不可读则用兜底值并留一行 debug。 */
|
|
31
|
+
export function resolveLlmTarget(ctx) {
|
|
32
|
+
const cur = typeof currentModelSelection === 'function' ? currentModelSelection(ctx) : null;
|
|
33
|
+
if (cur) return { provider: cur.provider, model: cur.model, followed: true };
|
|
34
|
+
console.debug('[dsh-ling] llm target fallback →', FALLBACK_PROVIDER + '/' + FALLBACK_MODEL,
|
|
35
|
+
'(agentDefaultModel.currentSelection 不可读;这不是用户的当前选择)');
|
|
36
|
+
return { provider: FALLBACK_PROVIDER, model: FALLBACK_MODEL, followed: false };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 达标判定:(较多真人轮次但文本少)或(轮次尚可且文本充足)。 */
|
|
40
|
+
export function passThreshold(users, chars) {
|
|
41
|
+
return (users >= 6 && chars >= 1500) || (users >= 2 && chars >= 6000);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** 一次性模型调用(手建请求);收集 text-delta 与 chunk 类型日志。
|
|
45
|
+
* 模型:**跟随当前选择**(显式传入 provider/model 时以入参为准,供测试/覆盖)。 */
|
|
46
|
+
export async function llmOnce(ctx, { system, text, maxTokens = DEEP_CFG.maxTokens, signal, effort = DEEP_CFG.effort, provider, model } = {}) {
|
|
47
|
+
const llm = svc(ctx, 'llm');
|
|
48
|
+
if (!llm || typeof llm.stream !== 'function') throw new Error('llm service unavailable');
|
|
49
|
+
const target = resolveLlmTarget(ctx);
|
|
50
|
+
const chunks = llm.stream({
|
|
51
|
+
provider: provider || target.provider,
|
|
52
|
+
model: model || target.model,
|
|
53
|
+
reasoningEffort: effort,
|
|
54
|
+
temperature: DEEP_CFG.temperature,
|
|
55
|
+
maxTokens,
|
|
56
|
+
signal,
|
|
57
|
+
system,
|
|
58
|
+
messages: [{
|
|
59
|
+
id: randomUUID(),
|
|
60
|
+
role: 'user',
|
|
61
|
+
content: [{ type: 'text', text: String(text) }],
|
|
62
|
+
source: { kind: 'user' },
|
|
63
|
+
}],
|
|
64
|
+
});
|
|
65
|
+
let out = '';
|
|
66
|
+
const chunkLog = [];
|
|
67
|
+
for await (const c of chunks) {
|
|
68
|
+
if (!c || typeof c.type !== 'string') continue;
|
|
69
|
+
if (chunkLog.length < 10) chunkLog.push(c.type);
|
|
70
|
+
if (c.type === 'text-delta' && typeof c.text === 'string') out += c.text;
|
|
71
|
+
if (c.type === 'error') throw new Error('llm stream error');
|
|
72
|
+
}
|
|
73
|
+
return { text: out, chunkLog, target };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 启动自检:先试 off(不思考)+足额预算;失败或空回复再试 low+大预算;仍空才判失败。 */
|
|
77
|
+
export async function probeLLM(ctx, memory) {
|
|
78
|
+
const attempts = [];
|
|
79
|
+
const run = async (effort, maxTokens) => {
|
|
80
|
+
const t0 = Date.now();
|
|
81
|
+
try {
|
|
82
|
+
const { text, chunkLog } = await llmOnce(ctx, {
|
|
83
|
+
system: 'Reply with exactly: pong',
|
|
84
|
+
text: 'ping',
|
|
85
|
+
maxTokens,
|
|
86
|
+
effort,
|
|
87
|
+
});
|
|
88
|
+
const reply = text.trim();
|
|
89
|
+
attempts.push({ effort, maxTokens, ok: reply.length > 0, reply: reply.slice(0, 40), chunkTypes: chunkLog, ms: Date.now() - t0 });
|
|
90
|
+
return reply.length > 0;
|
|
91
|
+
} catch (e) {
|
|
92
|
+
attempts.push({ effort, maxTokens, ok: false, error: String(e?.message ?? e).slice(0, 200) });
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
const ok = (await run('off', 300)) || (await run('low', 600));
|
|
97
|
+
const last = attempts[attempts.length - 1];
|
|
98
|
+
memory.kvSet('llm.probe', JSON.stringify({
|
|
99
|
+
ok,
|
|
100
|
+
attempts,
|
|
101
|
+
reply: ok ? last.reply : '(空回复)',
|
|
102
|
+
chunkTypes: last ? last.chunkTypes || null : null,
|
|
103
|
+
error: last && !ok ? last.error || null : null,
|
|
104
|
+
at: utcIso(),
|
|
105
|
+
}));
|
|
106
|
+
return { ok, attempts };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 在 ~/.dsh/sessions 下定位某会话的转储文件(代次无关:session[.vN].jsonl.zstd)。 */
|
|
110
|
+
export function findSessionFile(sessionId) {
|
|
111
|
+
const root = join(dshHome(), 'sessions');
|
|
112
|
+
let ws;
|
|
113
|
+
try { ws = readdirSync(root, { withFileTypes: true }); } catch { return null; }
|
|
114
|
+
for (const w of ws) {
|
|
115
|
+
if (!w.isDirectory()) continue;
|
|
116
|
+
const hit = pickSessionFile(join(root, w.name, String(sessionId)));
|
|
117
|
+
if (hit) return hit.f;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 从磁盘解码文件并统计/拼接 user/assistant 真实消息。 */
|
|
123
|
+
export function transcriptFromFile(file, { cap = DEEP_CFG.inputCap } = {}) {
|
|
124
|
+
const text = decodeAll(readFileSync(file));
|
|
125
|
+
const parts = [];
|
|
126
|
+
let users = 0;
|
|
127
|
+
let asst = 0;
|
|
128
|
+
for (const line of text.split('\n')) {
|
|
129
|
+
if (line.includes('"type":"user/message"')) {
|
|
130
|
+
if (!line.includes('"kind":"user"')) continue;
|
|
131
|
+
let ev;
|
|
132
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
133
|
+
const body = ev?.data?.content;
|
|
134
|
+
const u = Array.isArray(body)
|
|
135
|
+
? body.filter((b) => b?.type !== 'reasoning' && b?.type !== 'tool-call').map((b) => (typeof b === 'string' ? b : b?.text || b?.content || '')).join('\n')
|
|
136
|
+
: '';
|
|
137
|
+
if (!u) continue;
|
|
138
|
+
users += 1;
|
|
139
|
+
parts.push('[用户] ' + u.trim());
|
|
140
|
+
} else if (line.includes('"type":"assistant/message"')) {
|
|
141
|
+
let ev;
|
|
142
|
+
try { ev = JSON.parse(line); } catch { continue; }
|
|
143
|
+
const msg = ev?.data?.message;
|
|
144
|
+
const body = msg?.content;
|
|
145
|
+
const a = Array.isArray(body)
|
|
146
|
+
? body.filter((b) => b?.type === 'text' || !b?.type).map((b) => (typeof b === 'string' ? b : b?.text || b?.content || '')).join('\n')
|
|
147
|
+
: '';
|
|
148
|
+
if (!a) continue;
|
|
149
|
+
asst += 1;
|
|
150
|
+
parts.push('[AI] ' + a.trim());
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
let joined = parts.join('\n');
|
|
154
|
+
if (joined.length > cap) {
|
|
155
|
+
const mark = '\n…[中段省略]…\n';
|
|
156
|
+
const budget = Math.max(100, cap - mark.length);
|
|
157
|
+
joined = joined.slice(0, Math.floor(budget * 0.6)) + mark + joined.slice(-Math.floor(budget * 0.4));
|
|
158
|
+
}
|
|
159
|
+
return { text: joined, users, asst, chars: joined.length, file };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** DB 原文转录(role user/assistant,按 seq)。source 给定时按 G4 命名空间取原文
|
|
163
|
+
* (import 源先试 'import:<id>' 再回退裸 id;dsh/dsweb 用裸 id)。 */
|
|
164
|
+
export function buildTranscript(memory, sessionId, { cap = DEEP_CFG.inputCap, source } = {}) {
|
|
165
|
+
const stmt = memory.db.prepare(
|
|
166
|
+
"SELECT seq, role, text FROM dsh_turns_raw WHERE session_id=? AND role IN ('user','assistant') ORDER BY seq",
|
|
167
|
+
);
|
|
168
|
+
let rows = [];
|
|
169
|
+
for (const id of rawSessionCandidates(source, sessionId)) {
|
|
170
|
+
rows = stmt.all(id);
|
|
171
|
+
if (rows.length) break;
|
|
172
|
+
}
|
|
173
|
+
const parts = [];
|
|
174
|
+
let users = 0;
|
|
175
|
+
let total = 0;
|
|
176
|
+
for (const r of rows) {
|
|
177
|
+
const label = r.role === 'user' ? '[用户]' : '[AI]';
|
|
178
|
+
const t = String(r.text || '').trim();
|
|
179
|
+
if (!t) continue;
|
|
180
|
+
if (r.role === 'user') users += 1;
|
|
181
|
+
const line = `${label} ${t}`;
|
|
182
|
+
total += line.length;
|
|
183
|
+
parts.push(line);
|
|
184
|
+
}
|
|
185
|
+
let text = parts.join('\n');
|
|
186
|
+
if (text.length > cap) {
|
|
187
|
+
const mark = '\n…[中段省略]…\n';
|
|
188
|
+
const budget = Math.max(100, cap - mark.length);
|
|
189
|
+
text = text.slice(0, Math.floor(budget * 0.6)) + mark + text.slice(-Math.floor(budget * 0.4));
|
|
190
|
+
}
|
|
191
|
+
return { text, users, parts: parts.length, chars: text.length };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* 候选池:①DB 原文达标;②磁盘文件(≥diskMinBytes)有概述且未深摘。
|
|
196
|
+
* 返回 [{session_id, users?, chars?, lastTs, file?}] 按最近活跃降序。
|
|
197
|
+
*/
|
|
198
|
+
export function deepCandidates(memory, { limit = DEEP_CFG.perRunLimit } = {}) {
|
|
199
|
+
const rawRows = memory.db.prepare(
|
|
200
|
+
`SELECT t.session_id,
|
|
201
|
+
COUNT(*) FILTER (WHERE t.role='user') AS users,
|
|
202
|
+
COUNT(*) FILTER (WHERE t.role='assistant') AS asst,
|
|
203
|
+
COALESCE(SUM(LENGTH(t.text)),0) AS chars,
|
|
204
|
+
MAX(t.ts) AS lastTs
|
|
205
|
+
FROM dsh_turns_raw t
|
|
206
|
+
JOIN conv_overview o ON o.source='dsh' AND o.conv_id=t.session_id
|
|
207
|
+
GROUP BY t.session_id`,
|
|
208
|
+
).all();
|
|
209
|
+
const rawPass = new Set();
|
|
210
|
+
const rawOut = [];
|
|
211
|
+
for (const r of rawRows) {
|
|
212
|
+
if (memory.kvGet('deep:' + r.session_id)) continue; // 已深摘;skip 不约束 raw(会话长大后可重试)
|
|
213
|
+
if (!passThreshold(Number(r.users), Number(r.chars))) continue;
|
|
214
|
+
rawPass.add(r.session_id);
|
|
215
|
+
rawOut.push({ session_id: r.session_id, users: r.users, chars: r.chars, lastTs: r.lastTs, file: null });
|
|
216
|
+
}
|
|
217
|
+
rawOut.sort((a, b) => String(b.lastTs || '').localeCompare(String(a.lastTs || '')));
|
|
218
|
+
// 磁盘源:按文件大小降序(内容多者优先);跳过已摘/已判不合格
|
|
219
|
+
const ovs = memory.db.prepare("SELECT conv_id, updated_at FROM conv_overview WHERE source='dsh'").all();
|
|
220
|
+
const diskOut = [];
|
|
221
|
+
for (const o of ovs) {
|
|
222
|
+
if (rawPass.has(o.conv_id)) continue;
|
|
223
|
+
if (memory.kvGet('deep:' + o.conv_id) || memory.kvGet('deep.skip:' + o.conv_id)) continue;
|
|
224
|
+
const file = findSessionFile(o.conv_id);
|
|
225
|
+
if (!file) continue;
|
|
226
|
+
let size = 0;
|
|
227
|
+
try {
|
|
228
|
+
size = statSync(file).size;
|
|
229
|
+
} catch {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (size < DEEP_CFG.diskMinBytes) continue;
|
|
233
|
+
diskOut.push({ session_id: o.conv_id, users: 0, chars: 0, lastTs: o.updated_at, file, size });
|
|
234
|
+
}
|
|
235
|
+
diskOut.sort((a, b) => b.size - a.size);
|
|
236
|
+
return rawOut.concat(diskOut).slice(0, Math.max(1, Number(limit) || DEEP_CFG.perRunLimit));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const SYS = '你是用户的私人记忆摘要器。你会读到一段多轮对话记录(可能含代码/技术/生活内容)。请输出:第一行「主旨」,第二行「关键结论或产出」,第三行「用户的偏好/值得日后引用的点」。总输出不超过 500 字,不要客套,不要复述对话,不要用 markdown 标题。';
|
|
240
|
+
|
|
241
|
+
/** 对一个会话做深摘要并回写 overview.summary(原值存 kv deep.orig.<sid>)。 */
|
|
242
|
+
export async function deepSummarizeOne(ctx, memory, candidate) {
|
|
243
|
+
const sessionId = candidate.session_id;
|
|
244
|
+
// 档案行可能落在任一来源域(dsh / dsweb / import):按序查找后原域回写
|
|
245
|
+
const overview = memory.overviewById('dsh', String(sessionId))
|
|
246
|
+
|| memory.overviewById('dsweb', String(sessionId))
|
|
247
|
+
|| memory.overviewById('import', String(sessionId));
|
|
248
|
+
if (!overview) return { ok: false, reason: 'no-overview' };
|
|
249
|
+
let tr;
|
|
250
|
+
if (candidate.file) {
|
|
251
|
+
tr = transcriptFromFile(candidate.file);
|
|
252
|
+
} else {
|
|
253
|
+
tr = buildTranscript(memory, sessionId, { source: overview.source });
|
|
254
|
+
}
|
|
255
|
+
if (!tr.text || !passThreshold(tr.users, tr.chars)) {
|
|
256
|
+
// 不合格会话打跳过标记(避免每轮占坑重试);DB 原文后续长大会走 raw 通道重新评估
|
|
257
|
+
memory.kvSet('deep.skip:' + sessionId, 'below-min:' + tr.users + ':' + tr.chars + ':' + utcIso());
|
|
258
|
+
return { ok: false, reason: 'below-min', users: tr.users, chars: tr.chars };
|
|
259
|
+
}
|
|
260
|
+
const { text } = await llmOnce(ctx, { system: SYS, text: tr.text });
|
|
261
|
+
const summary = text.trim();
|
|
262
|
+
if (!summary) return { ok: false, reason: 'empty-output' };
|
|
263
|
+
if (!memory.kvGet('deep.orig:' + sessionId)) {
|
|
264
|
+
memory.kvSet('deep.orig:' + sessionId, overview.summary || '');
|
|
265
|
+
}
|
|
266
|
+
memory.upsertOverview({ ...overview, summary });
|
|
267
|
+
memory.kvSet('deep:' + sessionId, 'done:' + utcIso());
|
|
268
|
+
memory.kvSet('deep.sum:' + sessionId, summary); // 永久快照,防被浅版覆盖后丢失
|
|
269
|
+
return { ok: true, sessionId, chars: summary.length };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** 清掉 done/skip 标记,允许(或强制)重新深摘。 */
|
|
273
|
+
export function rerunOne(memory, sessionId) {
|
|
274
|
+
const sid = String(sessionId);
|
|
275
|
+
memory.kvSet('deep:' + sid, '');
|
|
276
|
+
memory.kvSet('deep.skip:' + sid, '');
|
|
277
|
+
return { ok: true, sessionId: sid };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** 候选来源:优先 DB 原文;否则磁盘文件。
|
|
281
|
+
* G4:同时兼容导入命名空间('import:<id>')与旧数据的裸 id。 */
|
|
282
|
+
export function candidateFor(memory, sessionId) {
|
|
283
|
+
const sid = String(sessionId);
|
|
284
|
+
const stmt = memory.db.prepare("SELECT COUNT(*) n FROM dsh_turns_raw WHERE session_id=? AND role='user'");
|
|
285
|
+
for (const id of [sid, RAW_IMPORT_PREFIX + sid]) {
|
|
286
|
+
const c = stmt.get(id);
|
|
287
|
+
if (c && Number(c.n) > 0) return { session_id: sid, file: null };
|
|
288
|
+
}
|
|
289
|
+
const file = findSessionFile(sid);
|
|
290
|
+
if (file) return { session_id: sid, file };
|
|
291
|
+
return { session_id: sid, file: null };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** import 源中带原文的会话,按体量降序(一键深摘/诞生仪式用)。
|
|
295
|
+
* excludeProcessed=true 时排除已 done 或已 skip 的会话——按钮"继续"推进的是未读部分,不重复重跑。 */
|
|
296
|
+
export function importRawTargets(memory, { limit = 50, excludeProcessed = false } = {}) {
|
|
297
|
+
const lim = Math.max(1, Math.min(200, Number(limit) || 50));
|
|
298
|
+
const all = memory.db.prepare(
|
|
299
|
+
`SELECT o.conv_id AS conv_id, SUM(LENGTH(t.text)) chars
|
|
300
|
+
FROM dsh_turns_raw t
|
|
301
|
+
JOIN conv_overview o ON o.source='import'
|
|
302
|
+
AND (o.conv_id = t.session_id OR (? || o.conv_id) = t.session_id)
|
|
303
|
+
GROUP BY o.conv_id ORDER BY chars DESC`,
|
|
304
|
+
).all(RAW_IMPORT_PREFIX);
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const r of all) {
|
|
307
|
+
const sid = String(r.conv_id);
|
|
308
|
+
if (excludeProcessed) {
|
|
309
|
+
const done = String(memory.kvGet('deep:' + sid) || '');
|
|
310
|
+
const skip = String(memory.kvGet('deep.skip:' + sid) || '');
|
|
311
|
+
if (done.trim() || skip.trim()) continue;
|
|
312
|
+
}
|
|
313
|
+
out.push({ session_id: sid, chars: Number(r.chars) });
|
|
314
|
+
if (out.length >= lim) break;
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 跑一轮深摘要(自动与手动共用);探针未通过/空回复时拒绝并说明。 */
|
|
320
|
+
export async function runDeepPass(ctx, memory, { limit } = {}) {
|
|
321
|
+
const probeRaw = memory.kvGet('llm.probe');
|
|
322
|
+
const probe = probeRaw ? JSON.parse(probeRaw) : null;
|
|
323
|
+
if (!probe || !probe.ok) {
|
|
324
|
+
return { ok: false, reason: 'probe-not-ok:' + (probe ? probe.error || probe.reply || 'empty-reply' : 'no-probe') };
|
|
325
|
+
}
|
|
326
|
+
const cands = deepCandidates(memory, { limit });
|
|
327
|
+
const report = { candidates: cands.length, done: [] };
|
|
328
|
+
for (const c of cands) {
|
|
329
|
+
try {
|
|
330
|
+
const r = await deepSummarizeOne(ctx, memory, c);
|
|
331
|
+
report.done.push({ session_id: c.session_id, ok: r.ok, chars: r.chars || null, reason: r.reason || null });
|
|
332
|
+
} catch (e) {
|
|
333
|
+
report.done.push({ session_id: c.session_id, ok: false, reason: String(e?.message ?? e).slice(0, 200) });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
memory.kvSet('deep.last', JSON.stringify({ at: utcIso(), ...report }));
|
|
337
|
+
return { ok: true, ...report };
|
|
338
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// dsh-ling host — dsweb(网页端历史)概述补摘要(2026-09-16 与用户定案)。
|
|
2
|
+
//
|
|
3
|
+
// 背景:dsweb 的 1500+ 条概述**只有标题、没有摘要**(扫描时只取首条用户消息抽关键词/分类),
|
|
4
|
+
// 而正文在**源库**里(deepseek_library.db 的 conversations / messages),不在我们的库。
|
|
5
|
+
// 补摘要因此必须回源库取内容 → 调模型 → 只更新 conv_overview.summary(其余字段原样保留)。
|
|
6
|
+
//
|
|
7
|
+
// 候选口径(用户 2026-09-16 拍板):
|
|
8
|
+
// 1) 排序:**命中优先**(hit_count>0,即真的进过 L1 开场记忆)→ 其余按**轮次数降序**;
|
|
9
|
+
// 2) 阈值:轮次数 < minTurns(默认 2)默认不补(纯闲聊),显式 --all 才含;
|
|
10
|
+
// 3) 引擎:默认**本机小助手**(零成本);**绝不静默切换到大模型**(那等于替用户花钱)。
|
|
11
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
12
|
+
|
|
13
|
+
// S7(2026-09-18 与用户定):默认地址**中性化**为 loopback —— 发布版绝不携带私有内网地址,
|
|
14
|
+
// 主人的真实小助手地址改由 settings.assistant(UI 可配)或环境变量指定,三级覆盖见 resolveAssistant()。
|
|
15
|
+
export const ASSISTANT_DEFAULT = 'http://127.0.0.1:11434/v1';
|
|
16
|
+
export const ASSISTANT_MODEL_DEFAULT = 'qwen3.5:9b';
|
|
17
|
+
export const MIN_TURNS_DEFAULT = 2;
|
|
18
|
+
export const MSG_BUDGET_DEFAULT = 2400; // 小助手上下文仅 4096 token,材料必须克制
|
|
19
|
+
export const PER_MSG_DEFAULT = 600;
|
|
20
|
+
export const MAX_TOKENS_DEFAULT = 512; // 小助手的下限,不得再低
|
|
21
|
+
export const SUMMARY_SYS = '你在为"历史会话"写一句摘要。只输出摘要本身:不超过 120 字,写清这次对话在做什么、结论是什么,不要客套、不要分点、不要引号、不要解释。';
|
|
22
|
+
/** Qwen3 的关思考软开关(2026-09-16 实测:该端点忽略 OpenAI 的 think 字段,但认这个)。 */
|
|
23
|
+
export const NO_THINK = '/no_think';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* S7:小助手目标的三级覆盖(2026-09-18 与用户定)。
|
|
27
|
+
* ① settings.assistant —— 界面可配,最高优先(主人的机器怎么连,主人说了算)
|
|
28
|
+
* ② 环境变量 DSH_LING_ASSISTANT / DSH_LING_ASSISTANT_MODEL —— 部署级
|
|
29
|
+
* ③ 内置默认 —— loopback,发布安全(绝不携带任何私有内网地址)
|
|
30
|
+
* 返回生效值 + **来源**,便于界面如实显示"这个地址是哪来的"。
|
|
31
|
+
*/
|
|
32
|
+
export function resolveAssistant(settings) {
|
|
33
|
+
const cfg = (settings && typeof settings.get === 'function' ? settings.get() : settings) || {};
|
|
34
|
+
const a = cfg.assistant || {};
|
|
35
|
+
const envBase = String(process.env.DSH_LING_ASSISTANT || '').trim();
|
|
36
|
+
const envModel = String(process.env.DSH_LING_ASSISTANT_MODEL || '').trim();
|
|
37
|
+
const setBase = String(a.baseUrl || '').trim();
|
|
38
|
+
const setModel = String(a.model || '').trim();
|
|
39
|
+
const baseUrl = setBase || envBase || ASSISTANT_DEFAULT;
|
|
40
|
+
const model = setModel || envModel || ASSISTANT_MODEL_DEFAULT;
|
|
41
|
+
const source = (setBase || setModel) ? 'settings' : ((envBase || envModel) ? 'env' : 'default');
|
|
42
|
+
return {
|
|
43
|
+
baseUrl, model, source,
|
|
44
|
+
defaults: { baseUrl: ASSISTANT_DEFAULT, model: ASSISTANT_MODEL_DEFAULT },
|
|
45
|
+
env: { baseUrl: envBase, model: envModel },
|
|
46
|
+
configured: { baseUrl: setBase, model: setModel },
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 打开源库(只读)。表结构按扫描器已验证的形态:conversations / messages。 */
|
|
51
|
+
export function openSource(dbPath) {
|
|
52
|
+
return new DatabaseSync(dbPath, { readOnly: true });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 源库里的会话(含轮次数 n_user;缺列时回退 0)。 */
|
|
56
|
+
export function listConversations(src, { minTurns = MIN_TURNS_DEFAULT, includeShort = false } = {}) {
|
|
57
|
+
let rows = [];
|
|
58
|
+
try {
|
|
59
|
+
rows = src.prepare('SELECT conv_id, title, n_user, updated_at, inserted_at FROM conversations').all();
|
|
60
|
+
} catch {
|
|
61
|
+
rows = src.prepare('SELECT conv_id, title FROM conversations').all();
|
|
62
|
+
}
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const r of rows) {
|
|
65
|
+
const n = Number(r.n_user ?? 0) || 0;
|
|
66
|
+
if (!includeShort && n < minTurns) continue;
|
|
67
|
+
out.push({ conv_id: String(r.conv_id), title: String(r.title || ''), n_user: n, updated_at: r.updated_at || r.inserted_at || '' });
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 取某会话的正文(user/assistant),按预算截断。 */
|
|
73
|
+
export function conversationText(src, convId, { budgetChars = MSG_BUDGET_DEFAULT, perMsg = PER_MSG_DEFAULT } = {}) {
|
|
74
|
+
let rows = [];
|
|
75
|
+
try {
|
|
76
|
+
rows = src.prepare(
|
|
77
|
+
"SELECT role, text FROM messages WHERE conv_id=? AND length(text)>0 ORDER BY seq LIMIT 60",
|
|
78
|
+
).all(String(convId));
|
|
79
|
+
} catch {
|
|
80
|
+
return '';
|
|
81
|
+
}
|
|
82
|
+
const parts = [];
|
|
83
|
+
let used = 0;
|
|
84
|
+
for (const r of rows) {
|
|
85
|
+
const role = String(r.role || '').toUpperCase() === 'USER' ? '用户' : 'AI';
|
|
86
|
+
const t = String(r.text || '').replace(/\s+/g, ' ').trim().slice(0, perMsg);
|
|
87
|
+
if (!t) continue;
|
|
88
|
+
const line = `${role}: ${t}`;
|
|
89
|
+
if (used + line.length > budgetChars) break;
|
|
90
|
+
parts.push(line);
|
|
91
|
+
used += line.length;
|
|
92
|
+
}
|
|
93
|
+
return parts.join('\n');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 候选 = 源库会话 ∩ 记忆库待补的行(hit_count 优先,再按轮次降序)。 */
|
|
97
|
+
export function buildCandidates(memory, src, { minTurns = MIN_TURNS_DEFAULT, includeShort = false, onlyHit = false } = {}) {
|
|
98
|
+
const overviews = memory.listOverviews({ onlyOk: true }).filter((r) => r.source === 'dsweb');
|
|
99
|
+
const byId = new Map(overviews.map((r) => [String(r.conv_id), r]));
|
|
100
|
+
const convs = listConversations(src, { minTurns, includeShort });
|
|
101
|
+
const list = [];
|
|
102
|
+
for (const c of convs) {
|
|
103
|
+
const ov = byId.get(c.conv_id);
|
|
104
|
+
if (!ov) continue;
|
|
105
|
+
const hit = Number(ov.hit_count ?? 0) || 0;
|
|
106
|
+
if (onlyHit && hit <= 0) continue;
|
|
107
|
+
if (String(ov.summary || '').trim()) continue; // 已有摘要的不重复补
|
|
108
|
+
list.push({ conv_id: c.conv_id, title: c.title || ov.title || '', n_user: c.n_user, hit_count: hit, category: ov.category || 'daily' });
|
|
109
|
+
}
|
|
110
|
+
list.sort((a, b) => (b.hit_count - a.hit_count) || (b.n_user - a.n_user) || String(a.conv_id).localeCompare(String(b.conv_id)));
|
|
111
|
+
return list;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 探测本机小助手(3 秒)。返回 {ok, models[], reason}。 */
|
|
115
|
+
export async function probeAssistant(baseUrl = ASSISTANT_DEFAULT, { timeoutMs = 3000 } = {}) {
|
|
116
|
+
const ctrl = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
118
|
+
try {
|
|
119
|
+
const res = await fetch(String(baseUrl).replace(/\/+$/, '') + '/models', { signal: ctrl.signal });
|
|
120
|
+
if (!res.ok) return { ok: false, models: [], reason: 'http-' + res.status };
|
|
121
|
+
const j = await res.json();
|
|
122
|
+
const models = (j?.data || []).map((m) => String(m.id || '')).filter(Boolean);
|
|
123
|
+
return { ok: models.length > 0, models, reason: models.length ? 'ok' : 'no-models' };
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { ok: false, models: [], reason: String(e?.name === 'AbortError' ? 'timeout' : (e?.message ?? e)).slice(0, 80) };
|
|
126
|
+
} finally {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 调本机小助手写摘要。返回 {ok, summary, error, ms, diag}。
|
|
133
|
+
*
|
|
134
|
+
* 关键实测(2026-09-16):
|
|
135
|
+
* · `/v1/chat/completions`(OpenAI 兼容)**不认** think:false,也**不认** /no_think 软开关 →
|
|
136
|
+
* 模型把 max_tokens 全用在 reasoning 上,`finish:'length'` 且正文为空(512→2133 字符思考;2048→4288 字符思考);
|
|
137
|
+
* · **Ollama 原生 `/api/chat` + `think:false` 有效**:实测 thinking 长度 0、正文正常。
|
|
138
|
+
* 因此默认走原生端点(把 base 末尾的 `/v1` 去掉),`noThink:false` 时才用兼容端点。
|
|
139
|
+
*/
|
|
140
|
+
export async function summarizeLocal(baseUrl, model, { system = SUMMARY_SYS, text, maxTokens = MAX_TOKENS_DEFAULT, timeoutMs = 90000, noThink = true } = {}) {
|
|
141
|
+
const t0 = Date.now();
|
|
142
|
+
const base = String(baseUrl).replace(/\/+$/, '');
|
|
143
|
+
const native = base.replace(/\/v1$/, '');
|
|
144
|
+
const url = noThink ? native + '/api/chat' : base + '/chat/completions';
|
|
145
|
+
const cap = Math.max(MAX_TOKENS_DEFAULT, Number(maxTokens) || MAX_TOKENS_DEFAULT);
|
|
146
|
+
const ctrl = new AbortController();
|
|
147
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
148
|
+
try {
|
|
149
|
+
const sys = noThink ? system + '\n' + NO_THINK : system;
|
|
150
|
+
const usr = noThink ? text + '\n' + NO_THINK : text;
|
|
151
|
+
const body = noThink
|
|
152
|
+
? {
|
|
153
|
+
model, stream: false, think: false,
|
|
154
|
+
messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }],
|
|
155
|
+
options: { temperature: 0.2, num_predict: cap },
|
|
156
|
+
}
|
|
157
|
+
: {
|
|
158
|
+
model, stream: false,
|
|
159
|
+
messages: [{ role: 'system', content: sys }, { role: 'user', content: usr }],
|
|
160
|
+
max_tokens: cap, temperature: 0.2,
|
|
161
|
+
};
|
|
162
|
+
const res = await fetch(url, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'content-type': 'application/json' },
|
|
165
|
+
signal: ctrl.signal,
|
|
166
|
+
body: JSON.stringify(body),
|
|
167
|
+
});
|
|
168
|
+
if (!res.ok) {
|
|
169
|
+
const t = await res.text().catch(() => '');
|
|
170
|
+
return { ok: false, error: 'http-' + res.status, ms: Date.now() - t0, diag: String(t).slice(0, 200), path: noThink ? 'native' : 'compat' };
|
|
171
|
+
}
|
|
172
|
+
const j = await res.json();
|
|
173
|
+
const msg = noThink ? (j?.message ?? {}) : (j?.choices?.[0]?.message ?? {});
|
|
174
|
+
const raw = String(msg.content ?? '');
|
|
175
|
+
const reasoning = String(msg.thinking ?? msg.reasoning_content ?? msg.reasoning ?? '');
|
|
176
|
+
const diag = {
|
|
177
|
+
path: noThink ? 'native' : 'compat',
|
|
178
|
+
finish: noThink ? (j?.done_reason ?? (j?.done ? 'stop' : null)) : (j?.choices?.[0]?.finish_reason ?? null),
|
|
179
|
+
contentLen: raw.length,
|
|
180
|
+
reasoningLen: reasoning.length,
|
|
181
|
+
completionTokens: noThink ? (j?.eval_count ?? null) : (j?.usage?.completion_tokens ?? null),
|
|
182
|
+
promptTokens: noThink ? (j?.prompt_eval_count ?? null) : (j?.usage?.prompt_tokens ?? null),
|
|
183
|
+
};
|
|
184
|
+
const summary = cleanSummary(raw);
|
|
185
|
+
return { ok: !!summary, summary, error: summary ? null : (diag.reasoningLen > 0 ? 'reasoning-only' : 'empty'), ms: Date.now() - t0, diag };
|
|
186
|
+
} catch (e) {
|
|
187
|
+
return { ok: false, error: String(e?.name === 'AbortError' ? 'timeout' : (e?.message ?? e)).slice(0, 120), ms: Date.now() - t0, path: noThink ? 'native' : 'compat' };
|
|
188
|
+
} finally {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** 清洗模型输出:去思考块/引号/换行,限长 300 字。 */
|
|
194
|
+
export function cleanSummary(raw) {
|
|
195
|
+
let s = String(raw || '');
|
|
196
|
+
s = s.replace(/<think(?:ing)?>[\s\S]*?<\/think(?:ing)?>/gi, '');
|
|
197
|
+
s = s.replace(/^\s*(摘要|总结|概述)\s*[::]\s*/i, '');
|
|
198
|
+
s = s.replace(/^["'「『]+|["'」』]+$/g, '');
|
|
199
|
+
s = s.replace(/\s+/g, ' ').trim();
|
|
200
|
+
if (s.length > 300) s = s.slice(0, 300);
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 带一次加预算重试的摘要调用(2026-09-16 实测教训):
|
|
206
|
+
* qwen3.x 默认思考,512 token 能被 reasoning 吃光 → `finish:'length'` 且正文为空。
|
|
207
|
+
* attempt 1:`/no_think` + 512;失败(reasoning-only/empty) → attempt 2:预算 ×4(给思考留够,正文才轮得上)。
|
|
208
|
+
* 返回 { ok, summary, error, ms, attempts:[{tag,maxTokens,ok,error,ms,diag}] }
|
|
209
|
+
*/
|
|
210
|
+
export async function summarizeWithRetry(baseUrl, model, { text, system = SUMMARY_SYS, maxTokens = MAX_TOKENS_DEFAULT, timeoutMs = 90000 } = {}) {
|
|
211
|
+
const attempts = [];
|
|
212
|
+
const first = await summarizeLocal(baseUrl, model, { system, text, maxTokens, timeoutMs });
|
|
213
|
+
attempts.push({ tag: 'no-think', maxTokens: Math.max(MAX_TOKENS_DEFAULT, maxTokens), ok: first.ok, error: first.error, ms: first.ms, diag: first.diag });
|
|
214
|
+
if (first.ok) return { ok: true, summary: first.summary, error: null, ms: first.ms, attempts };
|
|
215
|
+
const big = Math.max(2048, Math.max(MAX_TOKENS_DEFAULT, maxTokens) * 4);
|
|
216
|
+
const second = await summarizeLocal(baseUrl, model, { system, text, maxTokens: big, timeoutMs });
|
|
217
|
+
attempts.push({ tag: 'big-budget', maxTokens: big, ok: second.ok, error: second.error, ms: second.ms, diag: second.diag });
|
|
218
|
+
if (second.ok) return { ok: true, summary: second.summary, error: null, ms: second.ms, attempts };
|
|
219
|
+
return { ok: false, summary: '', error: second.error || first.error || 'failed', ms: second.ms, attempts };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 补摘要改的是记忆内容,必须 bump 版本 —— 否则长会话在空闲边界不会追平(只有概述器会 bump)。 */
|
|
223
|
+
export function touchMemoryVersion(memory, now = Date.now()) {
|
|
224
|
+
try { memory?.kvSet?.('memory_version', String(now)); return true; } catch { return false; }
|
|
225
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// dsh-ling host — 反馈成长回路(message-feedback 消费方,REPORT-04)。
|
|
2
|
+
// 平台 messageFeedback 无推送 → 低频轮询 list()。
|
|
3
|
+
// 语义(人审闭环):
|
|
4
|
+
// negative + note → 修订建议队列(绝不自动改人格;用户在 UI 逐条 采纳/忽略)
|
|
5
|
+
// positive → 该 DSH 会话概述热度加权(bumpHit)
|
|
6
|
+
// 去重:kv('fb.seen') 存已处理 key 集合(JSON,上限 FB_SEEN_CAP,先进先出)。
|
|
7
|
+
import { utcIso } from './util.js';
|
|
8
|
+
import { proposeHabit, resolveHabit, habitsOf } from './rules.js';
|
|
9
|
+
|
|
10
|
+
const FB_SEEN_CAP = 4000;
|
|
11
|
+
|
|
12
|
+
export function loadSeen(memory) {
|
|
13
|
+
try {
|
|
14
|
+
const v = memory.kvGet('fb.seen');
|
|
15
|
+
const arr = v ? JSON.parse(v) : [];
|
|
16
|
+
return Array.isArray(arr) ? arr : [];
|
|
17
|
+
} catch {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function saveSeen(memory, arr) {
|
|
23
|
+
const trimmed = arr.slice(-FB_SEEN_CAP);
|
|
24
|
+
memory.kvSet('fb.seen', JSON.stringify(trimmed));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 处理一批平台反馈条目(纯逻辑,便于单测)。
|
|
29
|
+
* @returns {{queued:number, boosted:number, seenAdded:number, skipped:number}}
|
|
30
|
+
*/
|
|
31
|
+
export function ingestFeedbackEntries(memory, entries, { seen } = {}) {
|
|
32
|
+
const seenSet = new Set(seen || loadSeen(memory));
|
|
33
|
+
const out = { queued: 0, boosted: 0, seenAdded: 0, skipped: 0 };
|
|
34
|
+
for (const e of entries || []) {
|
|
35
|
+
const sid = String(e?.sessionId ?? '');
|
|
36
|
+
const mid = String(e?.messageId ?? '');
|
|
37
|
+
const rating = String(e?.rating ?? '');
|
|
38
|
+
if (!sid || !mid || (rating !== 'positive' && rating !== 'negative')) {
|
|
39
|
+
out.skipped += 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const key = sid + ':' + mid;
|
|
43
|
+
if (seenSet.has(key)) {
|
|
44
|
+
out.skipped += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
seenSet.add(key);
|
|
48
|
+
out.seenAdded += 1;
|
|
49
|
+
if (rating === 'positive') {
|
|
50
|
+
// 点赞 → 强化对应 DSH 会话(若存在概述)
|
|
51
|
+
if (memory.overviewById('dsh', sid)) {
|
|
52
|
+
memory.bumpHit('dsh', sid);
|
|
53
|
+
out.boosted += 1;
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
const note = String(e?.note ?? '').trim();
|
|
57
|
+
if (note) {
|
|
58
|
+
memory.addFeedback({
|
|
59
|
+
session_id: sid, message_id: mid, rating, note,
|
|
60
|
+
created_at: e?.createdAt ? new Date(e.createdAt).toISOString() : utcIso(),
|
|
61
|
+
});
|
|
62
|
+
out.queued += 1;
|
|
63
|
+
} else {
|
|
64
|
+
out.skipped += 1; // 无文案的差评不入队列(信息量不足)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (out.seenAdded > 0) saveSeen(memory, [...seenSet]);
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 从平台 message_feedback sidecar 文件结构映射条目(tables.sessions 形态)。
|
|
74
|
+
* 服务 list() 需会话身份参数,不便轮询 → 直接读单文件 JSON。
|
|
75
|
+
*/
|
|
76
|
+
export function entriesFromFeedbackFile(fileObj) {
|
|
77
|
+
const out = [];
|
|
78
|
+
const tables = fileObj?.tables?.sessions;
|
|
79
|
+
if (!tables || typeof tables !== 'object') return out;
|
|
80
|
+
for (const sessionId of Object.keys(tables)) {
|
|
81
|
+
const row = tables[sessionId];
|
|
82
|
+
for (const item of row?.items || []) {
|
|
83
|
+
if (!item?.messageId || !item?.rating) continue;
|
|
84
|
+
out.push({
|
|
85
|
+
sessionId,
|
|
86
|
+
messageId: item.messageId,
|
|
87
|
+
rating: item.rating,
|
|
88
|
+
note: item.note ?? '',
|
|
89
|
+
createdAt: item.createdAt ?? item.updatedAt ?? Date.now(),
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** 采纳一条点踩建议(人审):走「提议 → 确认」落到**习惯**(2026-09-15 二分定稿;不再是规矩)。 */
|
|
97
|
+
export async function applySuggestionAsRule(memory, settings, id) {
|
|
98
|
+
const row = memory.getFeedback(Number(id));
|
|
99
|
+
if (!row) return { ok: false, reason: 'not-found' };
|
|
100
|
+
if (row.status !== 'new') return { ok: false, reason: 'status:' + row.status };
|
|
101
|
+
const note = String(row.note ?? '').trim();
|
|
102
|
+
if (!note) return { ok: false, reason: 'empty-note' };
|
|
103
|
+
const prop = await proposeHabit({ settings, habit: note, evidence: '平台点踩理由(面板采纳即确认)', byUser: true });
|
|
104
|
+
if (prop.ok) {
|
|
105
|
+
await resolveHabit({ settings, id: prop.id, action: 'confirm' });
|
|
106
|
+
} else if (prop.reason !== 'already-habit' && prop.reason !== 'already-pending') {
|
|
107
|
+
return { ok: false, reason: prop.reason };
|
|
108
|
+
}
|
|
109
|
+
memory.setFeedbackStatus(Number(id), 'applied');
|
|
110
|
+
return { ok: true, id: Number(id), habits: habitsOf(settings).length };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 忽略一条建议。 */
|
|
114
|
+
export function dismissSuggestion(memory, id) {
|
|
115
|
+
const row = memory.getFeedback(Number(id));
|
|
116
|
+
if (!row) return { ok: false, reason: 'not-found' };
|
|
117
|
+
memory.setFeedbackStatus(Number(id), 'dismissed');
|
|
118
|
+
return { ok: true, id: Number(id) };
|
|
119
|
+
}
|