@nightpainters/dsh-ling 1.5.2 → 1.5.3

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.
@@ -3,7 +3,7 @@
3
3
  // 写侧走传入的 MemoryStore(与运行中实例同库,免并发双开)。
4
4
  import { readdirSync, readFileSync, statSync } from 'node:fs';
5
5
  import { join } from 'node:path';
6
- import { homedir } from 'node:os';
6
+ import { dshHome } from './util.js'; // $DSH_HOME 优先,否则 ~/.dsh —— 与 deepsummary.js 同一个来源
7
7
  import { categorizeTitle } from './classify.js';
8
8
  import { keywordsFrom } from './inject-common.js';
9
9
  import { decodeAll } from './zstd.js';
@@ -42,9 +42,15 @@ export function pickSessionFile(dir) {
42
42
  * 为什么必须是一个显式函数:不传 root 时,扫描侧有默认值照常工作,而归因探针侧
43
43
  * 拿到 `undefined` → `readdirSync(undefined)` 抛错被吞 → 恒判 `no-sessions-root`,
44
44
  * 界面渲染成「未找到 DSH 会话目录:undefined」(BUG-2 归因几乎不可达)。
45
+ * ⚠️ 2026-09-29 修正:本函数原先是 `join(homedir(), '.dsh', 'sessions')`,只与**扫描侧**
46
+ * 同源;而**取原文侧** `deepsummary.js` 的 `findSessionFile()` 走 `join(dshHome(), 'sessions')`
47
+ * —— 同一语义两个来源。本机不设 DSH_HOME 时两者等价(缺陷因此长期隐身);一旦设了
48
+ * (例如导入测试台),两者就分叉:「本机 DSH 扫描」会去读**真机**会话目录 —— 不报错、只读,
49
+ * 但结果不可复现,还会把真历史灌进测试库。
50
+ * 现统一到 `dshHome()`(= `$DSH_HOME` 优先,否则 `~/.dsh`):扫描侧 / 归因探针侧 / 取原文侧三者同一个来源。
45
51
  */
46
52
  export function defaultSessionsRoot() {
47
- return join(homedir(), '.dsh', 'sessions');
53
+ return join(dshHome(), 'sessions');
48
54
  }
49
55
 
50
56
  /** 候选会话文件: ~/.dsh/sessions/<workspace>/<sid>/{session[.vN].jsonl.zstd} */
@@ -1,5 +1,11 @@
1
1
  // dsh-ling host — 反馈成长回路(message-feedback 消费方,REPORT-04)。
2
- // 平台 messageFeedback 无推送 → 低频轮询 list()。
2
+ // ⚠️ 2026-10-01 订正(旧注释"平台 messageFeedback 无推送 → 低频轮询 list()"**已被证伪**):
3
+ // 宿主 `@deepseek-ai/dsh-message-feedback` 有推送 —— 冷会话写入 flush 后
4
+ // `lib/index.js:288` 发 **`feedback/committed`**(载荷 = SessionInspection:
5
+ // `{ meta, inheritedEventCount, events }`,末条即本次提交的事件);
6
+ // 活会话消费方按它 README.zh.md:58 的口径看 `session/event` 里的
7
+ // `feedback/message-put` / `feedback/message-delete`。
8
+ // ⇒ 现走**事件驱动**(registerFeedbackListener),不再 60 秒轮询 sidecar。
3
9
  // 语义(人审闭环):
4
10
  // negative + note → 修订建议队列(绝不自动改人格;用户在 UI 逐条 采纳/忽略)
5
11
  // positive → 该 DSH 会话概述热度加权(bumpHit)
@@ -69,9 +75,99 @@ export function ingestFeedbackEntries(memory, entries, { seen } = {}) {
69
75
  return out;
70
76
  }
71
77
 
78
+ /** 事件名单一来源(测试钉它;改名时两处一起红,不会静默漂移)。 */
79
+ export const FEEDBACK_COMMITTED_EVENT = 'feedback/committed';
80
+
81
+ /**
82
+ * 提交事件列表 → 当前反馈条目(纯函数)。
83
+ * 与宿主 message-feedback 同口径:put 覆盖同 messageId 的旧值,delete 抹掉它。
84
+ * 末条是本次提交的事件 —— 无该项就返回空(不算错,也绝不抛)。
85
+ * @param {string} sid 所属会话
86
+ * @param {Array} events SessionInspection.events
87
+ * @returns {Array<{sessionId:string,messageId:string,rating:string,note:string,createdAt:number}>}
88
+ */
89
+ export function entriesFromSessionEvents(sid, events) {
90
+ const list = Array.isArray(events) ? events : [];
91
+ const last = list[list.length - 1];
92
+ const t = last?.type;
93
+ if (t !== 'feedback/message-put' && t !== 'feedback/message-delete') return [];
94
+ const sessionId = String(sid ?? last?.data?.sessionId ?? '');
95
+ if (!sessionId) return [];
96
+ const map = new Map();
97
+ for (const ev of list) {
98
+ const d = ev?.data;
99
+ if (!d) continue;
100
+ if (ev.type === 'feedback/message-put' && d.item?.messageId && d.item?.rating) {
101
+ map.set(String(d.item.messageId), {
102
+ messageId: String(d.item.messageId),
103
+ rating: String(d.item.rating),
104
+ note: d.item.note ?? '',
105
+ createdAt: d.item.createdAt ?? d.item.updatedAt ?? Date.now(),
106
+ });
107
+ } else if (ev.type === 'feedback/message-delete' && d.messageId) {
108
+ map.delete(String(d.messageId));
109
+ }
110
+ }
111
+ return [...map.values()].map((it) => ({ sessionId, ...it }));
112
+ }
113
+
114
+ /**
115
+ * 挂推送监听(U7 正解)。
116
+ * - 冷会话:`feedback/committed`,载荷是完整事件日志 ⇒ 重放出**当前**条目(update/delete 都算准);
117
+ * - 活会话:`session/event` 的 `feedback/message-put|delete`,按事件本身取条目;
118
+ * - delete 事件**不**反向撤单:已入队的修订建议保持原状(与旧轮询口径一致,登记在案)。
119
+ * 绝不抛:宿主在 `feedback/committed` 上等观察方,抛错只会让它写日志。
120
+ * @returns {() => void} 卸载函数(交给 ctx.effect / disposers)
121
+ */
122
+ export function registerFeedbackListener(ctx, memory) {
123
+ const offs = [];
124
+ const handle = (label, fn) => async (...args) => {
125
+ try {
126
+ fn(...args);
127
+ } catch (e) {
128
+ console.debug(`[dsh-ling] feedback ${label} handler error`, e);
129
+ }
130
+ };
131
+ try {
132
+ offs.push(ctx.on(FEEDBACK_COMMITTED_EVENT, handle('committed', (inspection) => {
133
+ // 只认驱动本回调的那条事件;其余类型视作"无提交"(宿主 no-op append 也会发这个通知)
134
+ const last = inspection?.events?.[inspection.events.length - 1];
135
+ if (last?.type !== 'feedback/message-put') return;
136
+ const sid = String(inspection?.meta?.id ?? last?.data?.sessionId ?? '');
137
+ const entries = entriesFromSessionEvents(sid, inspection?.events);
138
+ if (entries.length) processFeedbackEntries(memory, entries, { seen: loadSeen(memory) });
139
+ })));
140
+ } catch (e) {
141
+ console.debug('[dsh-ling] feedback committed subscribe failed', e);
142
+ }
143
+ try {
144
+ offs.push(ctx.on('session/event', handle('live-event', (session, event) => {
145
+ const t = event?.type;
146
+ if (t !== 'feedback/message-put' && t !== 'feedback/message-delete') return;
147
+ const sid = String(
148
+ session?.id ?? event?.data?.sessionId ?? session?.header?.id ?? '',
149
+ );
150
+ const entries = t === 'feedback/message-put'
151
+ ? entriesFromSessionEvents(sid, [event])
152
+ : [];
153
+ if (entries.length) processFeedbackEntries(memory, entries, { seen: loadSeen(memory) });
154
+ })));
155
+ } catch (e) {
156
+ console.debug('[dsh-ling] feedback live subscribe failed', e);
157
+ }
158
+ memory.kvSet('feedback.wired', FEEDBACK_COMMITTED_EVENT + '+session/event');
159
+ return () => {
160
+ for (const d of offs) {
161
+ try { d(); } catch { /* ignore */ }
162
+ }
163
+ };
164
+ }
165
+
72
166
  /**
73
167
  * 从平台 message_feedback sidecar 文件结构映射条目(tables.sessions 形态)。
74
- * 服务 list() 需会话身份参数,不便轮询 → 直接读单文件 JSON。
168
+ * ⚠️ 2026-10-01 起**不再是活路径**:宿主推送已接管(见文件头);
169
+ * 该 sidecar 在 Web 组合下自 2026/9/11 起零写入。此处只服务
170
+ * `tools/process-feedback.mjs` 这个人工兜底 CLI 与既有回归测试。
75
171
  */
76
172
  export function entriesFromFeedbackFile(fileObj) {
77
173
  const out = [];
@@ -117,3 +213,17 @@ export function dismissSuggestion(memory, id) {
117
213
  memory.setFeedbackStatus(Number(id), 'dismissed');
118
214
  return { ok: true, id: Number(id) };
119
215
  }
216
+
217
+ /**
218
+ * U7 正解入口:吃一批平台反馈,交给 ingestFeedbackEntries。
219
+ * 幂等:同一 (会话,消息) 只处理一次(kv `fb.seen`);返回 null = 这批没有可处理项。
220
+ * 注意 `{seen}` 必须是**本次调用前**的快照(调用后 seenSet 已含新键)。
221
+ */
222
+ export function processFeedbackEntries(memory, entries, { seen } = {}) {
223
+ const res = ingestFeedbackEntries(memory, entries, { seen });
224
+ if (res.queued + res.boosted + res.seenAdded > 0) {
225
+ memory.kvSet('feedback.last', JSON.stringify({ at: utcIso(), ...res }));
226
+ return res;
227
+ }
228
+ return null;
229
+ }
@@ -6,11 +6,61 @@ import { pick } from './util.js';
6
6
 
7
7
  const MODES = new Set(['work', 'life']);
8
8
 
9
+ /**
10
+ * A(审计 §四.4,2026-10-01 修):`_byId` 的**基数上限** —— 只对"全缺省空壳"条目生效。
11
+ *
12
+ * 为什么会有无界增长:`GET /state?sessionId=<任意字符串>` 经 stateFor → `gate.isRunning` /
13
+ * `pendingCount` / `snapshotOf`,而这三个方法都走 `_state()`,**先建条目再读** ⇒ 任意 id 都会
14
+ * 在内存里留下一条。条目不存在与条目为全缺省,对读者**不可区分**(实测:`/state` 这条路上
15
+ * 除 `_state()` 外没有任何写入点 —— mode 由 mode.js 从 memory/settings 读,不写 gate),
16
+ * 所以上限可以只清理空壳。
17
+ *
18
+ * 为什么不会动到真读数:带读数的条目**一律不淘汰** —— `running`(agent/status 写)、
19
+ * `pending`(act 写)、`snap`/`snapStale`(markSnap / 失效路径写)、`mode`(ensure 写)。
20
+ * 于是"已存在会话的读数不受影响"是**结构上**成立的(不是靠概率),
21
+ * `snapshotIds()` 枚举的定稿会话也不会因淘汰而消失。
22
+ *
23
+ * N=128 的依据:一台机器上真实并发的会话是个位数;带 snap 的定稿会话属于"非空壳",
24
+ * 根本不在这条上限的管辖内(故不会误伤历史)。128 比真实需要高约两个数量级,
25
+ * 同时把"任意 id ⇒ 无限增长"封成一个常数。要更紧/更松可经构造函数传 `maxIdleSessions`。
26
+ */
27
+ const MAX_IDLE_SESSIONS_DEFAULT = 128;
28
+
9
29
  export class FreezeGate {
10
- constructor({ onFlush } = {}) {
30
+ constructor({ onFlush, maxIdleSessions = MAX_IDLE_SESSIONS_DEFAULT } = {}) {
11
31
  this._byId = new Map();
12
32
  this._onFlush = onFlush || (() => {});
13
33
  this._recent = null; // 最近定稿快照的会话(API "当前会话"启发式)
34
+ const n = Number(maxIdleSessions);
35
+ this._maxIdle = Number.isFinite(n) && n >= 0 ? Math.floor(n) : MAX_IDLE_SESSIONS_DEFAULT;
36
+ }
37
+
38
+ /** 全缺省空壳?(新建时就是这个形状;任何真读数都会让它不再是空壳) */
39
+ _isIdleShell(s) {
40
+ return !s.running && !s.snap && !s.snapStale && !s.mode && s.pending.length === 0;
41
+ }
42
+
43
+ /**
44
+ * A:超过上限时,按**插入顺序**(Map 的迭代顺序)淘汰最老的**空壳**;带读数的一律留下。
45
+ * `keepId` = 刚建的那一条(它也是空壳,但必须留给本次调用方)。
46
+ * @returns {number} 本次淘汰条数(自检/测试可读)
47
+ */
48
+ _trimIdleShells(keepId) {
49
+ if (this._byId.size <= this._maxIdle) return 0;
50
+ let dropped = 0;
51
+ for (const [id, s] of this._byId) {
52
+ if (this._byId.size <= this._maxIdle) break;
53
+ if (id === keepId) continue; // 本次要用的那一条不许被自己挤掉
54
+ if (!this._isIdleShell(s)) continue; // 带读数:永不淘汰(这就是"读数不受影响"的保证)
55
+ this._byId.delete(id);
56
+ dropped += 1;
57
+ }
58
+ return dropped;
59
+ }
60
+
61
+ /** 门内会话条目数(自检/测试用;正常读者不需要它 —— 读数是 per-session 的)。 */
62
+ size() {
63
+ return this._byId.size;
14
64
  }
15
65
 
16
66
  /** @returns {{mode:string, running:boolean, pending:string[], snap:object|null}} */
@@ -19,6 +69,7 @@ export class FreezeGate {
19
69
  if (!s) {
20
70
  s = { mode: null, running: false, pending: [], snap: null, snapStale: false };
21
71
  this._byId.set(sessionId, s);
72
+ this._trimIdleShells(sessionId);
22
73
  }
23
74
  return s;
24
75
  }
package/lib/host/guard.js CHANGED
@@ -105,6 +105,26 @@ export function cookieValueOf(cookieHeader, name) {
105
105
  return undefined;
106
106
  }
107
107
 
108
+ /**
109
+ * 反向代理追加的**真实对端地址**(2026-09-30 加固⑤;判据与对照实验见 `tests\guard.test.mjs` 第 8 段「反代场景加固⑤」)。
110
+ *
111
+ * nginx 的 `$proxy_add_x_forwarded_for` = `$http_x_forwarded_for`, `$remote_addr`
112
+ * ⇒ **最后一段永远是 nginx 亲自追加、客户端无法影响的地址**(客户端伪造只能污染前面的段)。
113
+ * 无 XFF 时回退 `X-Real-IP`(同样是 nginx 亲自设置)。
114
+ *
115
+ * ⚠️ **不许"优化"成取第一段** —— 取首段时,攻击者只要自己先塞一句
116
+ * `X-Forwarded-For: 127.0.0.1`,整条判据立刻失效(同段「攻击②」有逐行对照)。
117
+ */
118
+ export function realPeerAddress(headers) {
119
+ const xff = headerOf(headers, 'x-forwarded-for');
120
+ if (typeof xff === 'string' && xff.trim() !== '') {
121
+ const parts = xff.split(',').map((s) => s.trim()).filter(Boolean);
122
+ if (parts.length) return parts[parts.length - 1];
123
+ }
124
+ const real = headerOf(headers, 'x-real-ip');
125
+ return typeof real === 'string' && real.trim() !== '' ? real.trim() : undefined;
126
+ }
127
+
108
128
  /**
109
129
  * 判定一次 /api 请求是否放行。
110
130
  * @param {object} headers 请求头
@@ -117,9 +137,26 @@ export function checkRequest(headers, { trustedHosts = [], allowLan = false } =
117
137
  if (!host) return { ok: false, reason: 'no-host' };
118
138
  const hostUrl = parseAuthority(host);
119
139
  if (!hostUrl) return { ok: false, reason: 'bad-host' };
140
+
141
+ const loopbackHost = isLoopbackHostname(hostUrl.hostname);
142
+
143
+ // ── 2026-09-30 加固⑤(反代场景):**loopback 免检只对"真的来自本机"的请求成立** ──────────
144
+ // 沿革:旧代码"Host 长得像 loopback 就直接短路跳过受信名单",这条假设在**引入反向代理之后失效** ——
145
+ // nginx 的 `proxy_set_header Host $http_host` 把 Host 变成**客户端的自我声明**,
146
+ // 而 cookie 那边只校验**名字**(名字是 `sha256(authority)` 的公开派生,客户端自算即可)⇒
147
+ // 「伪造 Host: 127.0.0.1:3080 + 自算 cookie 名」曾构成一条**不需要任何凭据**的公网通路。
148
+ // 判据:反向代理**一定会追加** XFF/X-Real-IP(`$proxy_add_x_forwarded_for` 把真实对端**追加在最后一段**),
149
+ // 所以"带着代理头、却声称自己是 loopback"= 伪造 ⇒ 拒。**无代理头的本机直连照旧免检**(见 §六 回归表)。
150
+ if (loopbackHost) {
151
+ const peer = realPeerAddress(headers);
152
+ if (peer !== undefined && !isLoopbackHostname(peer)) {
153
+ return { ok: false, reason: 'spoofed-loopback-host' };
154
+ }
155
+ }
156
+
120
157
  // 2026-09-17 加固③(G1):LAN 地址只在显式 allowLan 时才进受信集(旧版无条件并入,是整个 P0 的根)
121
158
  const trusted = allowLan ? [...lanAddresses(), ...trustedHosts] : trustedHosts;
122
- if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trusted)) {
159
+ if (!loopbackHost && !isTrustedAuthority(hostUrl, trusted)) {
123
160
  return { ok: false, reason: 'untrusted-host' };
124
161
  }
125
162
  if (headerOf(headers, 'sec-fetch-site') === 'cross-site') return { ok: false, reason: 'cross-site' };
@@ -15,6 +15,10 @@
15
15
  import { CORRECTION_BUCKETS } from './corpus-suggest.js';
16
16
  import { extractJson } from './genesis.js';
17
17
  import { isoDate } from './util.js';
18
+ // E2(2026-10-01):「哪些原文属于主人自己的会话」这一判据**不在这里另立一份** ——
19
+ // 它是全包唯一一处定义,住在 summarizer.js(正向白名单 + 未知来源 kv 告警)。
20
+ // 旧写法(黑名单式排除已知的 import: 前缀)在本文件里有 3 处,已统一改为共用判据。
21
+ import { dshSessionSql, noteUnknownRawNamespaces } from './summarizer.js';
18
22
 
19
23
  export const SCAN_DEFAULTS = Object.freeze({
20
24
  minHits: 4, // 同类纠正至少出现几次(2026-09-16 用户定案:3 → 4)
@@ -34,9 +38,11 @@ export function scanCorrections(memory, opts = {}) {
34
38
  const { minHits, minSessions, maxTurns, sampleLimit } = { ...SCAN_DEFAULTS, ...opts };
35
39
  let rows = [];
36
40
  try {
41
+ // E2:未知来源命名空间留痕(库干净时零写入);随后只扫 DSH 自己的会话。
42
+ noteUnknownRawNamespaces(memory, 'habit-gen.scanCorrections');
37
43
  rows = memory.db.prepare(
38
- // G4:习惯只能从「主人自己的会话」长出来 —— 排除导入命名空间(别人的对话)
39
- "SELECT session_id, text FROM dsh_turns_raw WHERE role='user' AND session_id NOT LIKE 'import:%' ORDER BY seq DESC LIMIT ?",
44
+ // G4:习惯只能从「主人自己的会话」长出来 —— 排除一切非 DSH 命名空间(别人的对话)
45
+ `SELECT session_id, text FROM dsh_turns_raw WHERE role='user' AND ${dshSessionSql()} ORDER BY seq DESC LIMIT ?`,
40
46
  ).all(maxTurns);
41
47
  } catch {
42
48
  return []; // 库结构异常时静默退化(生成器不能拖累别处)
@@ -149,12 +155,14 @@ export function buildReflectMaterial(memory, opts = {}) {
149
155
 
150
156
  // ③ 主人说过的话(真人原话;分轮读取 —— 每轮读上一轮没读过的那一段)
151
157
  try {
152
- const total = Number(memory?.db?.prepare?.("SELECT COUNT(*) n FROM dsh_turns_raw WHERE role='user' AND session_id NOT LIKE 'import:%'").get()?.n) || 0;
158
+ // E2:同上,未知来源命名空间留痕后按正向白名单统计(两处查询共用同一判据)
159
+ noteUnknownRawNamespaces(memory, 'habit-gen.buildReflectMaterial');
160
+ const total = Number(memory?.db?.prepare?.(`SELECT COUNT(*) n FROM dsh_turns_raw WHERE role='user' AND ${dshSessionSql()}`).get()?.n) || 0;
153
161
  stats.turnTotal = total;
154
162
  let off = Math.max(0, Number(cursor?.turnOffset) || 0);
155
163
  if (total > 0 && off >= total) { off = 0; stats.wrapped = true; }
156
164
  const rows = total > 0
157
- ? (memory.db.prepare("SELECT ts, text FROM dsh_turns_raw WHERE role='user' AND session_id NOT LIKE 'import:%' ORDER BY rowid DESC LIMIT ? OFFSET ?").all(turnLimit, off) || [])
165
+ ? (memory.db.prepare(`SELECT ts, text FROM dsh_turns_raw WHERE role='user' AND ${dshSessionSql()} ORDER BY rowid DESC LIMIT ? OFFSET ?`).all(turnLimit, off) || [])
158
166
  : [];
159
167
  const lines = rows
160
168
  .map((r) => { const t = clean(r?.text); return t ? `- (${isoDate(r?.ts) || '?'}) ${t.slice(0, 140)}` : ''; })
@@ -217,13 +217,14 @@ export function normalizeImportItem(raw) {
217
217
  * 落库一批(≤500/批,幂等):
218
218
  * - 折叠:同 conv_id 命中 dsweb 旧域(官方导出与历史抓取同源)→ 元数据与原文并入 dsweb,
219
219
  * 并移除本域残留副本(避免 L1 双份召回);
220
+ * ⚠️ A(2026-09-30):折叠**绝不覆盖目标会话已有的原文** —— 详见下面折叠分支的注释与 `foldSkippedRaw`;
220
221
  * - 否则:新行全量 upsert;已存在只刷新 标题/时间/类别/关键词(不动 summary/importance/置顶/热度);
221
222
  * - 完整档 raw 轮次按 (session_id,seq) 覆盖写,重复导入天然幂等。
222
- * @returns {{ok, newRows, refreshed, folded, removedImport, accepted, degraded, rejected}}
223
+ * @returns {{ok, newRows, refreshed, folded, removedImport, foldSkippedRaw, accepted, degraded, rejected}}
223
224
  */
224
225
  export async function applyImportItems(memory, items) {
225
226
  const rejected = [];
226
- const stat = { ok: true, accepted: 0, newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, degraded: 0, degradedReasons: {} };
227
+ const stat = { ok: true, accepted: 0, newRows: 0, refreshed: 0, upgraded: 0, folded: 0, removedImport: 0, foldSkippedRaw: 0, degraded: 0, degradedReasons: {} };
227
228
  const list = Array.isArray(items) ? items : [];
228
229
  for (let i = 0; i < list.length; i++) {
229
230
  const n = normalizeImportItem(list[i]);
@@ -254,9 +255,62 @@ export async function applyImportItems(memory, items) {
254
255
  stat.removedImport += 1;
255
256
  }
256
257
  stat.folded += 1;
257
- // 折叠分支的原文归 dsweb 域(裸 id):它本就与 dsweb 抓取同源,G4 的 import 前缀不适用
258
- for (const t of n.rawTurns) {
259
- memory.appendRawTurn(id, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text });
258
+ // 折叠分支的原文归 dsweb 域(裸 id):它本就与 dsweb 抓取同源,G4 的 import 前缀不适用。
259
+ // ── A(2026-09-30,修缺陷「折叠覆盖真原文」)────────────────────────────────
260
+ // 缺陷:旧实现把**导入侧的 seq** 直接 appendRawTurn 进这个 dsweb 会话,而
261
+ // `dsh_turns_raw` 的主键是 (session_id, seq)、`appendRawTurn` 是
262
+ // `ON CONFLICT(session_id, seq) DO UPDATE SET text=excluded.text`
263
+ // ⇒ 折叠侧 seq 从 1 重新数,与目标会话既有轮次**必然同号**,于是目标里的真原文
264
+ // 被逐条静默换成导入副本:**行数不增、内容被换、一个字都不报**(离线测试台 S6d 实测
265
+ // clobbered=3)。属"会丢数据 + 静默"那一档。
266
+ // 判据(顺序不可调):
267
+ // ① 目标**一个字原文都没有** ⇒ 照旧写入(这是"把原文补进去"的升级路径,**不许堵死**);
268
+ // ② 目标已有原文 ⇒ 逐条按 (role,text) 比:
269
+ // · 完全相同 ⇒ 跳过(幂等:重复导入同一份不报错、也不算丢失);
270
+ // · 不同 ⇒ **保留现有的**(绝不覆盖),计入 `foldSkippedRaw`。
271
+ // ⚠️ **不许**"按 max(seq) 往后追加"(同一段对话在库里变成两份 ⇒ 污染 L1 检索),
272
+ // **也不许**整段替换(那是丢真的方向)。
273
+ // 代价(已留证):目标已有原文且内容不同时,导入侧的这几轮**进不去** —— 这是**故意的**取舍:
274
+ // 宁可少一份副本,不可换掉真原文。它必须**说出来**(foldSkippedRaw → 回执/界面/账本),
275
+ // 静默与丢失同样不可接受。
276
+ // ── A'(2026-09-30 补,G4 裂缝「判据漏了 import 命名空间」)────────────────────
277
+ // 只看写入目标(裸 id)会漏 `import:<id>` 侧:若该会话在 import 命名空间下**已有原文**
278
+ // (例如先前做过一次普通文件导入),而裸 id 这侧 0 轮,折叠就会把同一段对话**再写一份**
279
+ // 进裸 id ⇒ 库里同一段对话出现两个 session_id、两份原文 —— 正是折叠机制本来要消灭的
280
+ // 「L1 双份召回」。故"已有原文"按**候选链任一侧**判(复用 memory.rawSessionCandidates,
281
+ // 与上方 upgraded 判据同一套口径,不另发明)。
282
+ // 任一侧已有原文 ⇒ 不写裸 id、也不动 `import:` 侧的行;被挡下的轮数照旧计入 foldSkippedRaw。
283
+ // ⚠️ 这里**不做**数据迁移(把 `import:` 侧原文搬到裸 id):那是另一档风险,留待拍板。
284
+ const targetHasRaw = rawSessionCandidates(IMPORT_SOURCE, id).some((k) => memory.rawTurnCount(k) > 0);
285
+ if (!targetHasRaw) {
286
+ for (const t of n.rawTurns) {
287
+ // v14(2026-10-01)来源列:这里**刻意不给** source,由 appendRawTurn 按 id 形状推导(裸 id ⇒ 'dsh')。
288
+ // 两条理由:① 本分支的写入目标由候选链决定,来源应当与**实际写进去的那个 id**同解(读者的回落
289
+ // 判据就是这个),写死一个常量会在 id 形态变化时与它脱钩;② 折叠进来的原文按现有裁决**仍被
290
+ // 概述器认领**(它落在裸 id 上)—— 若改记成 'dsweb',这些轮次会当场从概述器眼里消失,
291
+ // 那是**越权改裁决**,不属于"加一列"的授权范围(真机库实测该分支 0 行,留作残余风险上报)。
292
+ memory.appendRawTurn(id, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text });
293
+ }
294
+ } else if (n.rawTurns.length) {
295
+ // (role,text) 指纹:JSON 化再比 —— role 与 text 里可能出现分隔符,拼接字符串会误判相同。
296
+ // ── A''(2026-09-30 补,C′ 裂缝「指纹只取写入目标」)─────────────────────────
297
+ // A' 只把"要不要写"的存在判据扩到了候选链;**逐条比对的指纹**仍只取写入目标(裸 id)
298
+ // ⇒ 两侧都有原文时,`import:` 侧**已有的同一轮**再次进来会被算成"未替换"
299
+ // (计数偏大、方向保守:把幂等重复说成丢失)。既然"已有原文"按候选链任一侧判,
300
+ // 指纹集就必须**同一口径** —— 两侧的轮次合起来建集合,否则同一条链上并存两种口径。
301
+ // ⚠️ 本分支以 `targetHasRaw === true` 进入,里面**一行都不写** ⇒ 这里只让**计数更准**:
302
+ // 不动任何数据、不做迁移(`import:` 侧的行原样留着)。
303
+ const fp = (role, text) => JSON.stringify([String(role), String(text)]);
304
+ const have = new Set();
305
+ for (const k of rawSessionCandidates(IMPORT_SOURCE, id)) {
306
+ for (const r of memory.db.prepare('SELECT role, text FROM dsh_turns_raw WHERE session_id=?').all(k)) {
307
+ have.add(fp(r.role, r.text));
308
+ }
309
+ }
310
+ for (const t of n.rawTurns) {
311
+ if (have.has(fp(t.role, t.text))) continue; // 幂等:同 (role,text) 已在 ⇒ 跳过,不计
312
+ stat.foldSkippedRaw += 1; // 不同 ⇒ 保留现有的,只计数(一个字节都不写)
313
+ }
260
314
  }
261
315
  continue;
262
316
  }
@@ -282,9 +336,11 @@ export async function applyImportItems(memory, items) {
282
336
  }
283
337
  // G4(2026-09-17):导入原文落 'import:<id>' 命名空间 —— 与 DSH 会话原文物理隔离。
284
338
  // 否则概述器(GROUP BY session_id)会把它当成 DSH 会话重建(跨源双份),同 id 时还会互相覆盖原文。
339
+ // 写入侧坐实(2026-10-01 grep):本文件的实际来源命名空间 = `IMPORT_SOURCE`('import'),
340
+ // 落成的 id = `rawSessionId(IMPORT_SOURCE, id)` = `import:<id>` ⇒ v14 的 source 列记 'import'。
285
341
  const rawId = rawSessionId(IMPORT_SOURCE, id);
286
342
  for (const t of n.rawTurns) {
287
- memory.appendRawTurn(rawId, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text });
343
+ memory.appendRawTurn(rawId, { seq: t.seq, role: t.role, ts: t.ts, model: null, text: t.text, source: IMPORT_SOURCE });
288
344
  }
289
345
  }
290
346
  return { ok: true, ...stat, rejected };
@@ -4,6 +4,10 @@
4
4
  // host 端按 runId 累加进同一条账(批数/计数相加),日志不暴露 HTTP 细节;
5
5
  // - `at` = **导入开始时刻并固定**(首批写入后不再变动,账不会在列表里跳位置);`updatedAt` 记最后一批;
6
6
  // - 成功批由 /import/file/batch 自己记账;失败批由客户端经 /import/log 上报(只加 `errors` 批数);
7
+ // - #10(2026-09-29):被**跳过**的"单条过大"也进账(客户端在导入收尾时上报一次),但它记的是
8
+ // **跳过**、不是失败 ⇒ 走的是**不带 `errors`** 的那条路(`tooBig` / `tooBigNames`)。
9
+ // 以前 `/import/log` 只认 `errors`(且强制 ≥1),客户端只能**故意不报** ⇒ 账本上
10
+ // 完全看不出"有东西被跳过";现在这一笔补上了,且绝不混进"失败批"计数。
7
11
  // - 三条入口共用:文件导入 / 本机 DSH 扫描 / 网页端库扫描;
8
12
  // - kv `import.log.<at>` 存 JSON;保留最近 50 条,满了作废最老的。
9
13
 
@@ -24,6 +28,45 @@ export function cleanAt(value, now = Date.now()) {
24
28
  return Math.floor(n);
25
29
  }
26
30
 
31
+ /** ── #10:被跳过的"单条过大"(跳过 ≠ 失败)──────────────────────────────────
32
+ * 三个上限是**输入护栏**,不是显示偏好:账本要长期留着,不许任意长度的用户输入原样进来
33
+ * (`/import/log` 是 HTTP 端点)。`tooBig` 是"整次导入里被跳过的条数",10000 远超现实。
34
+ * ⚠️ `cleanTooBig` / `cleanTooBigNames` 是**唯一一份**实现:端点(`api.js`)也调它们,
35
+ * 不许在端点里另抄一份口径(否则两处必漂移)。 */
36
+ export const TOO_BIG_MAX = 10000;
37
+ /** 账本里最多留几个被跳过的条目名:与界面点名(前 5 条)同口径 —— 账本是摘要,不是清单。 */
38
+ export const TOO_BIG_NAMES_MAX = 5;
39
+ /** 单个名字的字符数上限:名字只用来"让人认出是哪一条",40 字够;超了就截,不丢整条。 */
40
+ export const TOO_BIG_NAME_CHARS = 40;
41
+
42
+ /** `tooBig` 归一:非负整数,夹在 [0, TOO_BIG_MAX];非数字/负数 → 0(不是 NaN、不是 undefined)。 */
43
+ export function cleanTooBig(v) {
44
+ const n = Math.floor(Number(v));
45
+ if (!Number.isFinite(n) || n <= 0) return 0;
46
+ return Math.min(n, TOO_BIG_MAX);
47
+ }
48
+
49
+ /** `tooBigNames` 归一:**只留非空字符串**、每条截到 40 字、最多 5 条(先来先留)。
50
+ * 非数组 → 空数组;坏输入只许"少记",绝不许抛(账本通路是尽力而为,不能因为一个字段 500)。 */
51
+ export function cleanTooBigNames(v) {
52
+ if (!Array.isArray(v)) return [];
53
+ const out = [];
54
+ for (const x of v) {
55
+ if (typeof x !== 'string') continue; // 非字符串项丢弃
56
+ const s = x.trim();
57
+ if (!s) continue; // 空/纯空白丢弃
58
+ out.push(s.length > TOO_BIG_NAME_CHARS ? s.slice(0, TOO_BIG_NAME_CHARS) : s);
59
+ if (out.length >= TOO_BIG_NAMES_MAX) break; // 只取前 5 条
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /** `foldSkippedRaw` 归一:与 `cleanTooBig` **同口径**(非负整数,夹在 [0, TOO_BIG_MAX]) —— 口径只留一份
65
+ * (两处各写一遍必漂移,这是本仓的老账)。
66
+ * 语义:A(2026-09-30)折叠时"因为目标会话**已有原文**而**没有替换**的轮数"。它同样是「跳过 ≠ 失败」
67
+ * 那一类(见 #10),所以:① **绝不**混进 `errors`;② `= 0`(含压根没这个字段)时落的账**逐字不变**。 */
68
+ export function cleanFoldSkipped(v) { return cleanTooBig(v); }
69
+
27
70
  function numberFields(entry) {
28
71
  const out = {};
29
72
  for (const k of NUM_KEYS) out[k] = Number(entry?.[k]) || 0;
@@ -67,6 +110,15 @@ export function logImport(memory, entry) {
67
110
  const name = String(entry?.name || '').slice(0, 120);
68
111
  const note = String(entry?.note || '').slice(0, 200);
69
112
  const kind = String(entry?.kind || 'file');
113
+ // #10:被跳过的"单条过大" —— 只有**上报了**它(>0)才入账。
114
+ // ⚠️ `tooBig === 0`(含压根没这个字段)时,下面落的账**逐字不变**(不加键、不改值、不改键序):
115
+ // 这是负对照,`tests/importlog.test.mjs` 有断言 —— 别为了"形状统一"给它俩补默认值。
116
+ const tooBig = cleanTooBig(entry?.tooBig);
117
+ const tooBigNames = cleanTooBigNames(entry?.tooBigNames);
118
+ // A(2026-09-30):折叠时"因为目标已有原文而**没有替换**的轮数" —— 与 tooBig 同款:**只有 >0 才入账**。
119
+ // ⚠️ 同一个负对照同样适用于它:`foldSkippedRaw === 0`(或没这个字段)时,落的账**逐字不变**
120
+ // (不进 NUM_KEYS:否则每一条文件导入的 JSON 都会凭空多一个 `foldSkippedRaw: 0` 键)。
121
+ const foldSkipped = cleanFoldSkipped(entry?.foldSkippedRaw);
70
122
 
71
123
  if (runId) {
72
124
  const found = findByRun(memory, runId);
@@ -76,6 +128,17 @@ export function logImport(memory, entry) {
76
128
  for (const k of NUM_KEYS) merged[k] = (Number(found.value[k]) || 0) + inc[k];
77
129
  if (!merged.name && name) merged.name = name; // 名字取首个非空
78
130
  if (note) merged.note = note; // 备注取最新
131
+ if (foldSkipped) {
132
+ // 同一 runId 的多批**累加**(与 tooBig/errors 同语义),合并后仍夹在上限内:
133
+ merged.foldSkippedRaw = Math.min(TOO_BIG_MAX, (Number(found.value.foldSkippedRaw) || 0) + foldSkipped);
134
+ }
135
+ if (tooBig) {
136
+ // 同一 runId 的多批**累加**(与 errors 同语义),但两项仍夹在上限内:
137
+ // 多批各报 5 个名字、30 批就是 150 个 —— 合并后同样只留前 5 条(账本是摘要不是清单)。
138
+ merged.tooBig = Math.min(TOO_BIG_MAX, (Number(found.value.tooBig) || 0) + tooBig);
139
+ const namesMerged = cleanTooBigNames([].concat(merged.tooBigNames || [], tooBigNames));
140
+ if (namesMerged.length) merged.tooBigNames = namesMerged;
141
+ }
79
142
  // at / startedAt / runId / kind 保持首值
80
143
  memory.kvSet(found.key, JSON.stringify(merged));
81
144
  prune(memory);
@@ -84,10 +147,16 @@ export function logImport(memory, entry) {
84
147
  }
85
148
 
86
149
  const key = keyFor(memory, at);
87
- memory.kvSet(key, JSON.stringify({
150
+ const rec = {
88
151
  at, startedAt: at, updatedAt: now, runId: runId || null,
89
152
  kind, name, batches, ...inc, note,
90
- }));
153
+ };
154
+ if (tooBig) {
155
+ rec.tooBig = tooBig;
156
+ if (tooBigNames.length) rec.tooBigNames = tooBigNames;
157
+ }
158
+ if (foldSkipped) rec.foldSkippedRaw = foldSkipped; // 同上:0 时不加键(键序与值逐字不变)
159
+ memory.kvSet(key, JSON.stringify(rec));
91
160
  prune(memory);
92
161
  return key;
93
162
  }
@@ -125,5 +194,14 @@ export function formatLogLine(item) {
125
194
  const batches = Number(item.batches) || 0;
126
195
  if (batches > 1) parts.push(`${batches} 批`);
127
196
  if (item.errors) parts.push(`失败 ${item.errors} 批`);
197
+ // #10:被跳过的"单条过大"必须在账本那一行上看得见(放在"失败 X 批"之后,只加不占位):
198
+ // 它是**已知且可解释**的一件事,与"失败批"是两码事 —— 不许因为没地方写就不写(那正是修前的样子)。
199
+ const tooBigN = Number(item.tooBig) || 0;
200
+ if (tooBigN > 0) parts.push(`单条过大 ${tooBigN}`);
201
+ // A(2026-09-30):折叠时"因为目标已有原文而**没有替换**的轮数"也要在账本这一行上看得见。
202
+ // 与「单条过大」同款:**只在末尾追加**(既有部分逐字不变),且 `= 0` 时行里一个字都不多 ——
203
+ // 它同样是**已知且可解释**的一件事(不是失败、不是丢失:目标是保住会话里原有的真原文)。
204
+ const foldSkippedN = Number(item.foldSkippedRaw) || 0;
205
+ if (foldSkippedN > 0) parts.push(`原文未替换 ${foldSkippedN}`);
128
206
  return `${when} · 文件导入${item.name ? '(' + item.name + ')' : ''} · ${parts.join(' · ')}`;
129
207
  }
@@ -2,7 +2,7 @@
2
2
  // session events → freeze gate state, snapshot freeze, incremental capture.
3
3
  // Every handler is defensive (payload shapes verified at M1b live probe).
4
4
  import { svc, pick, isMemoryEligibleHeader, sessionKind, utcIso } from './util.js';
5
- import { TRUNK_ID } from './memory.js';
5
+ import { TRUNK_ID, RAW_SOURCE_DSH } from './memory.js';
6
6
  import { freezeSnapshotForSession, invalidateSession, refreshIdleSnapshot } from './inject.js';
7
7
  import { summarizeDsh } from './summarizer.js';
8
8
  import { extractMessageText, messageSourceKind } from './snapshot.js';
@@ -151,7 +151,13 @@ export function wireLifecycle(ctx, gate, memory, settings, config) {
151
151
  }
152
152
  return;
153
153
  }
154
- if (type !== 'user/message' && type !== 'assistant/message') return;
154
+ // U7(2026-10-01):feedback/* 是平台消息反馈事件 —— 早先在这里被一并丢掉,
155
+ // 于是活会话里"当场点赞/点踩"根本没有下游。放行它们,交 feedback.js 的
156
+ // registerFeedbackListener 处理(它订阅同一个 session/event);本 handler 自己
157
+ // 只用 user/assistant 消息,下面照旧过滤,互不干扰。
158
+ const isFeedback = type === 'feedback/message-put' || type === 'feedback/message-delete';
159
+ if (!isFeedback && type !== 'user/message' && type !== 'assistant/message') return;
160
+ if (isFeedback) return; // 放行到下游订阅后,本 handler 不再往下走(免得写 dbg 诊断噪声)
155
161
  // 只留真人消息(排除 plugin/skill-catalog/goal 注入)
156
162
  if (type === 'user/message' && messageSourceKind(event) !== 'user') return;
157
163
  const sessionId = pick(
@@ -178,6 +184,9 @@ export function wireLifecycle(ctx, gate, memory, settings, config) {
178
184
  ts: time && !Number.isNaN(time) ? new Date(time).toISOString() : null,
179
185
  model: null,
180
186
  text,
187
+ // v14(2026-10-01):这里是**唯一**"主人自己的会话"写入点 ⇒ 来源当场记成 'dsh'。
188
+ // 不靠"id 里有没有冒号"推断:将来若宿主会话 id 形态变了(带冒号/带前缀),这一行仍然如实可读。
189
+ source: RAW_SOURCE_DSH,
181
190
  });
182
191
  if (isUser) stampModeIfFirstInput(memory, settings, String(sessionId));
183
192
  })),
@@ -352,6 +361,8 @@ export function captureSessionDiff(memory, session, settings = null) {
352
361
  ts: time && !Number.isNaN(time) ? new Date(time).toISOString() : null,
353
362
  model: null,
354
363
  text,
364
+ // v14:与 session/event 那条路同一来源口径(同一条链的两个入口,口径不许分叉)
365
+ source: RAW_SOURCE_DSH,
355
366
  });
356
367
  added += 1;
357
368
  if (type.startsWith('user')) userAdded += 1;