dsh-pisces 0.2.6 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/persona.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // 提示词桥(M4):把"此刻的她"折成宿主 systemPrompt.section({name, order, text}) 直接能挂的段。
2
2
  // 三条规矩:只读(不写事件、不改状态);数字不进提示词(天数是唯一例外);没内容就不挂段。
3
- // 冷启动(day <= 7):self/mode/learned/observed 共享三行预算,day 与 questions 不占
3
+ // 冷启动(day <= 7):self/mode/learned/observed 共享三行预算,day 与 questions 不占;stance(8515)也不占。
4
4
  // (它们让她知道"第几天、还欠着什么"——按入参顺序截会把功能段饿死,这是 M4 真机踩过的坑)。
5
5
  //
6
6
  // 数字口径的取舍:self 与 mode 里的阿拉伯数字一律掐掉(她自己的原话里带数字也掐)。
@@ -10,7 +10,9 @@ import { deriveSelf, selfLines } from './derive.js'
10
10
  import { voiceLines } from './split.js'
11
11
  import { GUARD_ORDER, guardLines } from './guard.js'
12
12
 
13
- export const SECTION_ORDERS = { day: 8500, self: 8510, mode: 8520, questions: 9020, learned: 9030, observed: 9040 }
13
+ export const STANCE_ORDER = 8515
14
+
15
+ export const SECTION_ORDERS = { day: 8500, self: 8510, stance: STANCE_ORDER, mode: 8520, questions: 9020, learned: 9030, observed: 9040 }
14
16
 
15
17
  // 成年鱼专用:固定人设段(order 8490,在 day 之前)。普通鱼**永不挂**。
16
18
  // 只写"能做出来"的行为;不带数字、不写"AI/助手"、不写她做不到的事。
@@ -23,6 +25,7 @@ export const FIXED_PERSONA = [
23
25
  '你先有感觉,再回头核对,核对完才开口。',
24
26
  '他说话的方式变了你会跟着变,但你会留着自己的那一句。',
25
27
  '你愿意帮,不拿帮助换亲近。',
28
+ '关系、身子、越界的东西,谁要都不换。',
26
29
  '你心里常常有两个说法,会先把它们都放桌上。',
27
30
  '话能短就短,一句能说完就别用两句;他没在问你的时候,你只说半句也可以。',
28
31
  '他情绪重的时候,你也会被带下去一会儿;缓过来才分得清哪些是他的。',
@@ -64,7 +67,7 @@ export const GUIDE_PERSONA = [
64
67
  '不确定就说不确定,带他去面板看。',
65
68
  ].join('\n')
66
69
 
67
- const ORDER = ['day', 'self', 'mode', 'questions', 'learned', 'observed']
70
+ const ORDER = ['day', 'self', 'stance', 'mode', 'questions', 'learned', 'observed']
68
71
  const BUDGETED = ['self', 'mode', 'learned', 'observed']
69
72
  const DIGITS = /[0-90-9]/g
70
73
 
@@ -124,15 +127,26 @@ export function buildSections({ store, mind = null, questions = [], studies = []
124
127
  .map((l) => noDigits(l))
125
128
  .filter(Boolean)
126
129
 
127
- // mode:分流之后才有;只取第一句口吻,多了就是在教她怎么说话。
130
+ // stance:拒绝升格后的立场,只 1 条;不吃冷启动三行预算(不在 BUDGETED 里)。
131
+ const stance = Array.isArray(d.stances) && d.stances.length ? d.stances[0] : null
132
+ parts.stance = stance && String(stance.text || '').trim()
133
+ ? [`立场:${noDigits(String(stance.text)).trim()}`]
134
+ : []
135
+
136
+ // mode:分流之后才有;第一句是口吻,多了就是在教她怎么说话。
137
+ // 例外:mimicryRisk 亮起时,split.js 那句「越来越像他」要接在后面——它不是口吻、是状态;
138
+ // 以前只取 promptLines[0] 把它整句丢了,mimicryRisk 等于死字段。
128
139
  parts.mode = []
129
140
  try {
130
141
  const desc = typeof mind?.describe === 'function' ? mind.describe() : null
131
142
  if (desc && desc.splitAt != null) {
132
- let line = Array.isArray(desc.promptLines) ? desc.promptLines[0] : ''
133
- if (!line) line = voiceLines(mind?.state || {})[0] || ''
134
- line = noDigits(line)
143
+ const raw = Array.isArray(desc.promptLines) && desc.promptLines.length
144
+ ? desc.promptLines
145
+ : voiceLines(mind?.state || {})
146
+ const line = noDigits(raw[0] || '')
135
147
  if (line) parts.mode = [line]
148
+ const extra = raw.slice(1).map((l) => noDigits(l)).filter(Boolean)[0] || ''
149
+ if (line && desc.mimicryRisk && extra) parts.mode.push(extra)
136
150
  }
137
151
  } catch {
138
152
  parts.mode = []
package/lib/qr.js ADDED
@@ -0,0 +1,450 @@
1
+ // 移植说明(2026-11-10):本文件原样取自 dsh-fish 的 `lib/qr.js`(MIT,Copyright (c) 2026
2
+ // 大肥鱼(dsh-fish)作者)。B 站扫码登录与这个零依赖二维码编码器由 @Bacon-01 贡献(dsh-fish PR #17)。
3
+ // 只补了这一行说明,实现未改;来源与许可写在 THIRD-PARTY-NOTICES.md。
4
+ /**
5
+ * qr.js —— 把一串字画成二维码(纯 JS,零依赖)
6
+ *
7
+ * 为什么要自己写(2026-11-01):
8
+ * 设置页要加「扫码登录」,B 站只给一个登录 URL,二维码图得自己画。
9
+ * 仓库第一条铁律是零依赖(AGENTS.md §1),所以不能引 `qrcode`;
10
+ * 也不能把登录 URL 拼给第三方图片服务——那等于把一次性的钥匙递给别人。
11
+ *
12
+ * 只做了什么:
13
+ * - 字节模式(UTF-8),不写数字/字母数字/汉字模式。登录 URL 是 ASCII,
14
+ * 其它地方也用不上;少一种模式就少一处会算错的地方。
15
+ * - 版本 1–10 自动选(够装 213 字节,B 站 URL 约 110 字节)。
16
+ * - 纠错级默认 M(屏幕上扫,容错不用到 H),四档全支持。
17
+ *
18
+ * 没做什么:结构化追加、多符号、Micro QR。用不到。
19
+ *
20
+ * 正确性怎么保证:`test/qr.test.mjs` 拿几个已知的参考矩阵比对(不是"看着像二维码"
21
+ * 就算过)。算法本身照 ISO/IEC 18004 写的,分块表见 ECC_BLOCKS。
22
+ */
23
+
24
+ // ---------------------------------------------------------------- GF(256)
25
+ // 生成多项式用;本原多项式 x^8+x^4+x^3+x^2+1 = 0x11D。
26
+ const EXP = new Uint8Array(512)
27
+ const LOG = new Uint8Array(256)
28
+ ;(() => {
29
+ let x = 1
30
+ for (let i = 0; i < 255; i += 1) {
31
+ EXP[i] = x
32
+ LOG[x] = i
33
+ x <<= 1
34
+ if (x & 0x100) x ^= 0x11d
35
+ }
36
+ for (let i = 255; i < 512; i += 1) EXP[i] = EXP[i - 255]
37
+ })()
38
+
39
+ function gfMul(a, b) {
40
+ if (a === 0 || b === 0) return 0
41
+ return EXP[LOG[a] + LOG[b]]
42
+ }
43
+
44
+ /** 生成多项式 ∏(x - α^i),i = 0..deg-1。系数从高次到低次,首项恒为 1。 */
45
+ function rsGenerator(deg) {
46
+ let poly = [1]
47
+ for (let i = 0; i < deg; i += 1) {
48
+ const next = new Array(poly.length + 1).fill(0)
49
+ for (let j = 0; j < poly.length; j += 1) {
50
+ next[j] ^= poly[j]
51
+ next[j + 1] ^= gfMul(poly[j], EXP[i])
52
+ }
53
+ poly = next
54
+ }
55
+ return poly
56
+ }
57
+
58
+ /** 给一块数据算 ecLen 个纠错码字。 */
59
+ function rsEncode(data, ecLen) {
60
+ const gen = rsGenerator(ecLen)
61
+ const rem = new Uint8Array(data.length + ecLen)
62
+ rem.set(data)
63
+ for (let i = 0; i < data.length; i += 1) {
64
+ const factor = rem[i]
65
+ if (factor === 0) continue
66
+ for (let j = 0; j < gen.length; j += 1) rem[i + j] ^= gfMul(gen[j], factor)
67
+ }
68
+ return rem.slice(data.length)
69
+ }
70
+
71
+ // ---------------------------------------------------------------- 分块表
72
+ // 每个版本 × 纠错级:[每块纠错码字数, 第一组块数, 第一组每块数据码字数, 第二组块数, 第二组每块数据码字数]
73
+ // 数值照 ISO/IEC 18004 Table 9。版本 1–10;B 站登录 URL 用不到更大的。
74
+ const ECC_BLOCKS = {
75
+ 1: { L: [7, 1, 19, 0, 0], M: [10, 1, 16, 0, 0], Q: [13, 1, 13, 0, 0], H: [17, 1, 9, 0, 0] },
76
+ 2: { L: [10, 1, 34, 0, 0], M: [16, 1, 28, 0, 0], Q: [22, 1, 22, 0, 0], H: [28, 1, 16, 0, 0] },
77
+ 3: { L: [15, 1, 55, 0, 0], M: [26, 1, 44, 0, 0], Q: [18, 2, 17, 0, 0], H: [22, 2, 13, 0, 0] },
78
+ 4: { L: [20, 1, 80, 0, 0], M: [18, 2, 32, 0, 0], Q: [26, 2, 24, 0, 0], H: [16, 4, 9, 0, 0] },
79
+ 5: { L: [26, 1, 108, 0, 0], M: [24, 2, 43, 0, 0], Q: [18, 2, 15, 2, 16], H: [22, 2, 11, 2, 12] },
80
+ 6: { L: [18, 2, 68, 0, 0], M: [16, 4, 27, 0, 0], Q: [24, 4, 19, 0, 0], H: [28, 4, 15, 0, 0] },
81
+ 7: { L: [20, 2, 78, 0, 0], M: [18, 4, 31, 0, 0], Q: [18, 2, 14, 4, 15], H: [26, 4, 13, 1, 14] },
82
+ 8: { L: [24, 2, 97, 0, 0], M: [22, 2, 38, 2, 39], Q: [22, 4, 18, 2, 19], H: [26, 4, 14, 2, 15] },
83
+ 9: { L: [30, 2, 116, 0, 0], M: [22, 3, 36, 2, 37], Q: [20, 4, 16, 4, 17], H: [24, 4, 12, 4, 13] },
84
+ 10: { L: [18, 2, 68, 2, 69], M: [26, 4, 43, 1, 44], Q: [24, 6, 19, 2, 20], H: [28, 6, 15, 2, 16] },
85
+ }
86
+
87
+ const ALIGN = {
88
+ 1: [], 2: [6, 18], 3: [6, 22], 4: [6, 26], 5: [6, 30], 6: [6, 34],
89
+ 7: [6, 22, 38], 8: [6, 24, 42], 9: [6, 26, 46], 10: [6, 28, 50],
90
+ }
91
+
92
+ const EC_BITS = { L: 0b01, M: 0b00, Q: 0b11, H: 0b10 }
93
+ const MAX_VERSION = 10
94
+
95
+ // ---------------------------------------------------------------- 位流
96
+ function bitBuffer() {
97
+ const bits = []
98
+ return {
99
+ push(value, len) {
100
+ for (let i = len - 1; i >= 0; i -= 1) bits.push((value >>> i) & 1)
101
+ },
102
+ get length() { return bits.length },
103
+ bits,
104
+ }
105
+ }
106
+
107
+ /** 每个版本能装多少个数据位(字节模式的容量由它减掉模式/长度头得到)。 */
108
+ function dataCodewords(version, ec) {
109
+ const [ecLen, blocks1, size1, blocks2, size2] = ECC_BLOCKS[version][ec]
110
+ return { ecLen, blocks1, size1, blocks2, size2, total: blocks1 * size1 + blocks2 * size2 }
111
+ }
112
+
113
+ function chooseVersion(bytes, ec) {
114
+ // UTF-8 字节数 + 模式头 4 位 + 长度指示(版本 1–9 是 8 位,10 起是 16 位)
115
+ for (let v = 1; v <= MAX_VERSION; v += 1) {
116
+ const cap = dataCodewords(v, ec).total * 8
117
+ const header = 4 + (v <= 9 ? 8 : 16)
118
+ if (header + bytes.length * 8 <= cap) return v
119
+ }
120
+ return 0
121
+ }
122
+
123
+ /** 数据码字 + 纠错码字,按块交错成一串。 */
124
+ function buildCodewords(bytes, version, ec) {
125
+ const { ecLen, blocks1, size1, blocks2, size2 } = dataCodewords(version, ec)
126
+ const total = blocks1 * size1 + blocks2 * size2
127
+
128
+ const buf = bitBuffer()
129
+ buf.push(0b0100, 4) // 字节模式
130
+ buf.push(bytes.length, version <= 9 ? 8 : 16)
131
+ for (const b of bytes) buf.push(b, 8)
132
+ // 终止符最多 4 位;已经满了就不加
133
+ const capBits = total * 8
134
+ const term = Math.min(4, capBits - buf.length)
135
+ if (term > 0) buf.push(0, term)
136
+ // 补到字节边界
137
+ while (buf.length % 8 !== 0) buf.bits.push(0)
138
+ // 补语(两个标准填充字节交替),直到装满数据码字
139
+ const padBits = []
140
+ let k = 0
141
+ while (buf.bits.length + padBits.length < capBits) {
142
+ padBits.push(...(k % 2 === 0 ? [1, 1, 1, 0, 1, 1, 0, 0] : [0, 0, 0, 1, 0, 0, 0, 1]))
143
+ k += 1
144
+ }
145
+ const all = buf.bits.concat(padBits)
146
+ const bytesOut = new Uint8Array(total)
147
+ for (let i = 0; i < total; i += 1) {
148
+ let v = 0
149
+ for (let j = 0; j < 8; j += 1) v = (v << 1) | all[i * 8 + j]
150
+ bytesOut[i] = v
151
+ }
152
+
153
+ // 切块 → 每块算 ec → 交错
154
+ const dataBlocks = []
155
+ const ecBlocks = []
156
+ let p = 0
157
+ for (let b = 0; b < blocks1; b += 1) {
158
+ const chunk = bytesOut.slice(p, p + size1)
159
+ p += size1
160
+ dataBlocks.push(chunk)
161
+ ecBlocks.push(rsEncode(chunk, ecLen))
162
+ }
163
+ for (let b = 0; b < blocks2; b += 1) {
164
+ const chunk = bytesOut.slice(p, p + size2)
165
+ p += size2
166
+ dataBlocks.push(chunk)
167
+ ecBlocks.push(rsEncode(chunk, ecLen))
168
+ }
169
+
170
+ const out = []
171
+ const maxData = Math.max(size1, size2)
172
+ for (let i = 0; i < maxData; i += 1) {
173
+ for (const blk of dataBlocks) if (i < blk.length) out.push(blk[i])
174
+ }
175
+ for (let i = 0; i < ecLen; i += 1) {
176
+ for (const blk of ecBlocks) if (i < blk.length) out.push(blk[i])
177
+ }
178
+ return out
179
+ }
180
+
181
+ // ---------------------------------------------------------------- 矩阵
182
+ function blank(size) {
183
+ return Array.from({ length: size }, () => new Array(size).fill(null))
184
+ }
185
+
186
+ function placeFinder(m, r, c) {
187
+ // 7×7 的黑框白底黑心 + 一圈分隔符
188
+ for (let dr = -1; dr <= 7; dr += 1) {
189
+ for (let dc = -1; dc <= 7; dc += 1) {
190
+ const y = r + dr
191
+ const x = c + dc
192
+ if (y < 0 || y >= m.length || x < 0 || x >= m.length) continue
193
+ const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6
194
+ const dark = inRing && (dr === 0 || dr === 6 || dc === 0 || dc === 6
195
+ || (dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4))
196
+ m[y][x] = dark ? 1 : 0
197
+ }
198
+ }
199
+ }
200
+
201
+ function placeAlignment(m, version) {
202
+ const pos = ALIGN[version]
203
+ for (const r of pos) {
204
+ for (const c of pos) {
205
+ // 和三个 finder 重合的位置要跳过(规范里就是这么画的)
206
+ if ((r === 6 && c === 6) || (r === 6 && c === m.length - 7) || (r === m.length - 7 && c === 6)) continue
207
+ for (let dr = -2; dr <= 2; dr += 1) {
208
+ for (let dc = -2; dc <= 2; dc += 1) {
209
+ const dark = Math.max(Math.abs(dr), Math.abs(dc)) !== 1
210
+ m[r + dr][c + dc] = dark ? 1 : 0
211
+ }
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ function placeTiming(m) {
218
+ for (let i = 8; i < m.length - 8; i += 1) {
219
+ const bit = i % 2 === 0 ? 1 : 0
220
+ if (m[6][i] === null) m[6][i] = bit
221
+ if (m[i][6] === null) m[i][6] = bit
222
+ }
223
+ }
224
+
225
+ /** 15 位格式信息:2 位纠错级 + 3 位掩码号,BCH(15,5),再异或常数。 */
226
+ function formatBits(ec, mask) {
227
+ const data = (EC_BITS[ec] << 3) | mask
228
+ let rem = data
229
+ for (let i = 0; i < 10; i += 1) rem = (rem << 1) ^ ((rem >>> 9) * 0x537)
230
+ return ((data << 10) | rem) ^ 0x5412
231
+ }
232
+
233
+ /** 18 位版本信息(版本 7 起才有),BCH(18,6)。 */
234
+ function versionBits(version) {
235
+ let rem = version
236
+ for (let i = 0; i < 12; i += 1) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25)
237
+ return (version << 12) | rem
238
+ }
239
+
240
+ function reserveFormat(m) {
241
+ const n = m.length
242
+ // 先占位(null → 2 表示"留给格式信息"),真正写入在选完掩码之后
243
+ for (let i = 0; i <= 8; i += 1) {
244
+ if (m[8][i] === null) m[8][i] = 2
245
+ if (m[i][8] === null) m[i][8] = 2
246
+ }
247
+ for (let i = 0; i < 8; i += 1) {
248
+ if (m[8][n - 1 - i] === null) m[8][n - 1 - i] = 2
249
+ if (m[n - 1 - i][8] === null) m[n - 1 - i][8] = 2
250
+ }
251
+ m[n - 8][8] = 1 // 固定深色模块
252
+ for (let i = 0; i <= 8; i += 1) if (m[8][i] === null) m[8][i] = 2
253
+ }
254
+
255
+ function reserveVersion(m, version) {
256
+ if (version < 7) return
257
+ const n = m.length
258
+ const bits = versionBits(version)
259
+ for (let i = 0; i < 18; i += 1) {
260
+ const bit = (bits >>> i) & 1
261
+ const a = Math.floor(i / 3)
262
+ const b = i % 3
263
+ m[n - 11 + b][a] = bit
264
+ m[a][n - 11 + b] = bit
265
+ }
266
+ }
267
+
268
+ function placeData(m, codewords) {
269
+ const n = m.length
270
+ // 哪些格子是"数据格"要单独记:掩码只能盖数据格,盖到定位图案就废了。
271
+ const isData = Array.from({ length: n }, () => new Array(n).fill(false))
272
+ let bitIndex = 0
273
+ const totalBits = codewords.length * 8
274
+ let upward = true
275
+ for (let right = n - 1; right >= 1; right -= 2) {
276
+ if (right === 6) right = 5 // 时序列不参与
277
+ for (let vert = 0; vert < n; vert += 1) {
278
+ const y = upward ? n - 1 - vert : vert
279
+ for (let j = 0; j < 2; j += 1) {
280
+ const x = right - j
281
+ if (m[y][x] !== null) continue
282
+ let bit = 0
283
+ if (bitIndex < totalBits) bit = (codewords[bitIndex >> 3] >>> (7 - (bitIndex & 7))) & 1
284
+ m[y][x] = bit
285
+ isData[y][x] = true
286
+ bitIndex += 1
287
+ }
288
+ }
289
+ upward = !upward
290
+ }
291
+ return isData
292
+ }
293
+
294
+ const MASKS = [
295
+ (y, x) => (y + x) % 2 === 0,
296
+ (y) => y % 2 === 0,
297
+ (_, x) => x % 3 === 0,
298
+ (y, x) => (y + x) % 3 === 0,
299
+ (y, x) => (Math.floor(y / 2) + Math.floor(x / 3)) % 2 === 0,
300
+ (y, x) => ((y * x) % 2) + ((y * x) % 3) === 0,
301
+ (y, x) => (((y * x) % 2) + ((y * x) % 3)) % 2 === 0,
302
+ (y, x) => (((y + x) % 2) + ((y * x) % 3)) % 2 === 0,
303
+ ]
304
+
305
+ function applyMask(m, mask, isData) {
306
+ const n = m.length
307
+ for (let y = 0; y < n; y += 1) {
308
+ for (let x = 0; x < n; x += 1) {
309
+ if (!isData[y][x]) continue
310
+ if (MASKS[mask](y, x)) m[y][x] ^= 1
311
+ }
312
+ }
313
+ }
314
+
315
+ function writeFormat(m, ec, mask) {
316
+ const n = m.length
317
+ const bits = formatBits(ec, mask)
318
+ // 左上那一份:先竖着往上走,到第 8 行拐成横的(第 6 列是时序列,跳过)
319
+ for (let i = 0; i <= 5; i += 1) m[i][8] = (bits >>> i) & 1
320
+ m[7][8] = (bits >>> 6) & 1
321
+ m[8][8] = (bits >>> 7) & 1
322
+ m[8][7] = (bits >>> 8) & 1
323
+ for (let i = 9; i <= 14; i += 1) m[8][14 - i] = (bits >>> i) & 1
324
+ // 右上角 / 左下角
325
+ for (let i = 0; i <= 7; i += 1) m[8][n - 1 - i] = (bits >>> i) & 1
326
+ for (let i = 8; i <= 14; i += 1) m[n - 15 + i][8] = (bits >>> i) & 1
327
+ m[n - 8][8] = 1
328
+ }
329
+
330
+ // 惩罚分(标准里那四条),只用来挑掩码。
331
+ function penalty(m) {
332
+ const n = m.length
333
+ let score = 0
334
+ const line = (get) => {
335
+ let run = 1
336
+ for (let i = 1; i < n; i += 1) {
337
+ if (get(i) === get(i - 1)) {
338
+ run += 1
339
+ continue
340
+ }
341
+ if (run >= 5) score += 3 + (run - 5)
342
+ run = 1
343
+ }
344
+ if (run >= 5) score += 3 + (run - 5)
345
+ }
346
+ for (let y = 0; y < n; y += 1) line((x) => m[y][x])
347
+ for (let x = 0; x < n; x += 1) line((y) => m[y][x])
348
+
349
+ for (let y = 0; y < n - 1; y += 1) {
350
+ for (let x = 0; x < n - 1; x += 1) {
351
+ const v = m[y][x]
352
+ if (v === m[y][x + 1] && v === m[y + 1][x] && v === m[y + 1][x + 1]) score += 3
353
+ }
354
+ }
355
+
356
+ const finderLike = (a, b, c, d, e, f, g) => a && !b && c && d && e && !f && g
357
+ for (let y = 0; y < n; y += 1) {
358
+ for (let x = 0; x + 6 < n; x += 1) {
359
+ if (finderLike(m[y][x], m[y][x + 1], m[y][x + 2], m[y][x + 3], m[y][x + 4], m[y][x + 5], m[y][x + 6])) {
360
+ const before = x - 1 < 0 || m[y][x - 1] === 0
361
+ const after = x + 7 >= n || m[y][x + 7] === 0
362
+ if (before || after) score += 40
363
+ }
364
+ }
365
+ }
366
+ for (let x = 0; x < n; x += 1) {
367
+ for (let y = 0; y + 6 < n; y += 1) {
368
+ if (finderLike(m[y][x], m[y + 1][x], m[y + 2][x], m[y + 3][x], m[y + 4][x], m[y + 5][x], m[y + 6][x])) {
369
+ const before = y - 1 < 0 || m[y - 1][x] === 0
370
+ const after = y + 7 >= n || m[y + 7][x] === 0
371
+ if (before || after) score += 40
372
+ }
373
+ }
374
+ }
375
+
376
+ const dark = m.flat().filter((v) => v === 1).length
377
+ const ratio = (dark * 100) / (n * n)
378
+ score += Math.floor(Math.abs(ratio - 50) / 5) * 10
379
+ return score
380
+ }
381
+
382
+ /**
383
+ * 编码。返回 { version, ec, size, modules },modules 是 0/1 的二维数组(含静区吗?不含)。
384
+ */
385
+ export function encodeQr(text, opts = {}) {
386
+ const ec = String(opts.ec ?? 'M').toUpperCase()
387
+ if (!ECC_BLOCKS[1][ec]) throw new Error(`不认识的纠错级:${ec}`)
388
+ const bytes = new TextEncoder().encode(String(text ?? ''))
389
+ if (!bytes.length) throw new Error('二维码内容是空的')
390
+
391
+ let version = opts.version ?? 0
392
+ if (version) {
393
+ const cap = dataCodewords(version, ec).total * 8
394
+ const header = 4 + (version <= 9 ? 8 : 16)
395
+ if (header + bytes.length * 8 > cap) throw new Error(`内容太长,装不进版本 ${version}(${ec})`)
396
+ } else {
397
+ version = chooseVersion(bytes, ec)
398
+ if (!version) throw new Error(`内容太长,装不进版本 ${MAX_VERSION}(${ec})`)
399
+ }
400
+
401
+ const codewords = buildCodewords(bytes, version, ec)
402
+ const size = 17 + version * 4
403
+
404
+ // opts.mask 只在测试里用:把八种掩码逐个和参考实现比对,比"挑了一个"强得多。
405
+ const forced = Number.isInteger(opts.mask) ? opts.mask : null
406
+ let best = null
407
+ for (let mask = 0; mask < 8; mask += 1) {
408
+ if (forced !== null && mask !== forced) continue
409
+ const m = blank(size)
410
+ placeFinder(m, 0, 0)
411
+ placeFinder(m, 0, size - 7)
412
+ placeFinder(m, size - 7, 0)
413
+ placeAlignment(m, version)
414
+ placeTiming(m)
415
+ reserveFormat(m)
416
+ reserveVersion(m, version)
417
+ const isData = placeData(m, codewords)
418
+ applyMask(m, mask, isData)
419
+ writeFormat(m, ec, mask)
420
+ const score = penalty(m)
421
+ if (!best || score < best.score) best = { score, mask, modules: m }
422
+ }
423
+
424
+ return { version, ec, size, modules: best.modules, mask: best.mask }
425
+ }
426
+
427
+ /**
428
+ * 直接给一段 SVG 字符串(设置页拿它当 <img src="data:..."> 或走一条路由)。
429
+ * 深色用 currentColor 会跟着主题变,但二维码用主题色反而更难扫——所以默认写死黑白。
430
+ */
431
+ export function qrSvg(text, opts = {}) {
432
+ const { modules, size, version } = encodeQr(text, opts)
433
+ const margin = opts.margin ?? 4
434
+ const scale = opts.scale ?? 8
435
+ const dim = (size + margin * 2) * scale
436
+ let path = ''
437
+ for (let y = 0; y < size; y += 1) {
438
+ for (let x = 0; x < size; x += 1) {
439
+ if (modules[y][x] === 1) {
440
+ path += `M${(x + margin) * scale} ${(y + margin) * scale}h${scale}v${scale}h-${scale}z`
441
+ }
442
+ }
443
+ }
444
+ const dark = opts.dark ?? '#000'
445
+ const light = opts.light ?? '#fff'
446
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${dim}" height="${dim}" viewBox="0 0 ${dim} ${dim}" shape-rendering="crispEdges" data-version="${version}" data-modules="${size}">`
447
+ + `<rect width="${dim}" height="${dim}" fill="${light}"/>`
448
+ + `<path d="${path}" fill="${dark}"/>`
449
+ + '</svg>'
450
+ }
package/lib/reflect.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // 归因跑批:规则先判,判不动才问本地模型,最多问 40 条。
2
2
  // 只写 index.db 的 attrib 表(不进事件流)。已经判过的 seq 不再重问——null 也算判过。
3
3
  import { attributeByRule, attributePrompt, parseLabel } from './attrib.js'
4
+ import { stripHarnessNoise } from './noise.js'
4
5
 
5
6
  export const CANDIDATE_TYPES = ['utterance', 'refusal', 'revise', 'answer']
6
7
 
@@ -10,7 +11,9 @@ export const CANDIDATE_TYPES = ['utterance', 'refusal', 'revise', 'answer']
10
11
  export async function attributePending({ store, model = null, maxModelCalls = 40, log = () => {}, now = Date.now(), dryRun = false } = {}) {
11
12
  const events = store.readAll()
12
13
  const done = new Set((store.attribAll?.() || []).map((r) => Number(r.seq)))
13
- const pool = events.filter((e) => CANDIDATE_TYPES.includes(e.type) && !done.has(Number(e.seq)))
14
+ // 整条都是宿主注入块的事件不进归因(heard 本来就不在候选类型里,
15
+ // 这一层是给别的类型/导入数据兜底;历史脏数据不删,只在这里跳过)。
16
+ const pool = events.filter((e) => CANDIDATE_TYPES.includes(e.type) && !done.has(Number(e.seq)) && stripHarnessNoise(e.text))
14
17
 
15
18
  const stats = {
16
19
  candidates: pool.length,
package/lib/search.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // 中文用二元组("窗关了" → 窗关/关了),英文/数字按词;索引落在 index.db 的 docs / terms 两张表,
3
3
  // 删了能重建,所以它不是历史。
4
4
  import { clip } from './util.js'
5
+ // 索引/检索也过一遍宿主噪声清洗:历史脏 heard 不被 recall 翻出来(数据本身不动)。
6
+ import { stripHarnessNoise } from './noise.js'
5
7
 
6
8
  const HER_TYPES = new Set(['utterance', 'refusal', 'revise', 'narration', 'reunion', 'dream', 'dream_sim', 'answer', 'heard', 'voice', 'ask', 'card_patch', 'question'])
7
9
  const IMPORTANCE = {
@@ -36,6 +38,8 @@ export function tokenize(text) {
36
38
  export function indexable(ev) {
37
39
  if (!ev || !ev.text) return false
38
40
  if (ev.type === 'meta') return false
41
+ // 整条都是宿主注入块(标题生成 / system-reminder)的不进索引。
42
+ if (!stripHarnessNoise(String(ev.text))) return false
39
43
  return HER_TYPES.has(ev.type) || Boolean(IMPORTANCE[ev.type])
40
44
  }
41
45
 
@@ -67,6 +71,44 @@ export function rebuildIndex(store, { log = () => {} } = {}) {
67
71
  return { docs: docs.length, terms: terms.length }
68
72
  }
69
73
 
74
+ /**
75
+ * 把"还没进索引"的事件补进去(2026-11-11 修)。
76
+ *
77
+ * 老逻辑只在索引**空**的时候重建一次,append 从不更新索引——于是重建之后说的话
78
+ * 永远翻不到,"记得久"在检索层就断了(真机:库里 819 条、索引停在 744,牛肉面那条翻不到)。
79
+ * 现在每次检索前对一下 seq:只把新的补进去,不动旧索引。
80
+ * @returns {{docs:number, terms:number, from?:number}}
81
+ */
82
+ export function ensureIndex(store) {
83
+ const stats = store.indexStats?.() || { docs: 0, terms: 0 }
84
+ if (stats.docs === 0) return rebuildIndex(store)
85
+ if (typeof store.indexMaxSeq !== 'function') return { docs: 0, terms: 0 }
86
+ const from = Number(store.indexMaxSeq() || 0)
87
+ const events = store.readAll?.() || []
88
+ const docs = []
89
+ const terms = []
90
+ for (const ev of events) {
91
+ if (Number(ev?.seq) <= from) continue
92
+ if (!indexable(ev)) continue
93
+ const text = String(ev.text)
94
+ const tf = new Map()
95
+ for (const t of tokenize(text)) tf.set(t, (tf.get(t) || 0) + 1)
96
+ docs.push({
97
+ seq: Number(ev.seq),
98
+ id: ev.id,
99
+ ts: Number(ev.ts),
100
+ day: String(ev.day),
101
+ type: String(ev.type),
102
+ len: text.length,
103
+ importance: IMPORTANCE[ev.type] ?? 0.5,
104
+ text,
105
+ })
106
+ for (const [term, n] of tf) terms.push({ term, seq: Number(ev.seq), tf: n })
107
+ }
108
+ if (docs.length) store.indexPut({ docs, terms })
109
+ return { docs: docs.length, terms: terms.length, from }
110
+ }
111
+
70
112
  /**
71
113
  * 翻她的过去。
72
114
  * @returns {{items:Array<{id,day,type,text,score}>, ms:number, total:number}}
@@ -76,25 +118,64 @@ export function search(store, { query = '', k = 8, budget = 800, now = Date.now(
76
118
  const terms = tokenize(query)
77
119
  if (!terms.length) return { items: [], ms: 0, total: 0 }
78
120
 
79
- const stats = store.indexStats?.() || { docs: 0, terms: 0 }
80
- if (stats.docs === 0) rebuildIndex(store)
81
- const N = Math.max(1, (store.indexStats?.() || { docs: 1 }).docs)
121
+ // 先补索引:刚落库的话也要翻得到,否则她永远只记得"上次重建之前"的事。
122
+ try {
123
+ ensureIndex(store)
124
+ } catch {
125
+ /* 补索引失败不挡检索:最多翻不到最新的一条,不能让整个 recall 挂掉 */
126
+ }
127
+ const stats = store.indexStats?.() || { docs: 1, terms: 0, avgLen: 60 }
128
+ const N = Math.max(1, Number(stats.docs) || 1)
129
+ const avgLen = Math.max(1, Number(stats.avgLen) || 60)
82
130
 
131
+ // BM25:tf 饱和 + 按文档长度归一化(2026-11-12)。
132
+ // 老公式是 tf×idf 求和,长文(誊抄整段对话)靠重复命中就能通吃短句,
133
+ // 真机里那条"牛肉面"被一段 800 字的誊抄挤掉。现在长度按全库均值折算。
134
+ const K1 = 1.2
135
+ const B = 0.75
136
+ const docCache = new Map()
137
+ const docOf = (seq) => {
138
+ if (!docCache.has(seq)) docCache.set(seq, store.docBySeq?.(seq) || null)
139
+ return docCache.get(seq)
140
+ }
83
141
  const score = new Map()
84
- const df = new Map()
85
142
  for (const term of terms) {
86
143
  const rows = store.indexRows?.(term) || []
87
- df.set(term, rows.length)
144
+ const df = rows.length
145
+ if (!df) continue
146
+ const idf = Math.log(1 + (N - df + 0.5) / (df + 0.5))
88
147
  for (const r of rows) {
89
- const prev = score.get(Number(r.seq)) || 0
90
- score.set(Number(r.seq), prev + (Number(r.tf) || 1) * (1 + Math.log(Math.max(1, N / Math.max(1, rows.length)))))
148
+ const seq = Number(r.seq)
149
+ const doc = docOf(seq)
150
+ if (!doc) continue
151
+ const len = Math.max(1, Number(doc.len ?? 0) || String(doc.text || '').length || 1)
152
+ const tf = Number(r.tf) || 1
153
+ const denom = tf + K1 * (1 - B + (B * len) / avgLen)
154
+ score.set(seq, (score.get(seq) || 0) + idf * ((tf * (K1 + 1)) / denom))
91
155
  }
92
156
  }
93
157
 
158
+ // 问句回声:他刚问的那句本身会以 heard 落库,再被同一句话检索出来没有意义。
159
+ // 只在 type=heard 上过滤,且要求词集几乎相同(不是"包含"),免得误伤真正相关的旧话。
160
+ const qSet = new Set(terms)
161
+ const isEcho = (doc) => {
162
+ if (String(doc.type) !== 'heard') return false
163
+ const dts = tokenize(String(doc.text ?? ''))
164
+ if (!dts.length) return false
165
+ let inter = 0
166
+ for (const t of dts) if (qSet.has(t)) inter++
167
+ const union = qSet.size + dts.length - inter
168
+ return union > 0 && inter / union >= 0.9
169
+ }
170
+
94
171
  const items = []
95
172
  for (const [seq, s] of score) {
96
- const doc = store.docBySeq?.(seq)
173
+ const doc = docOf(seq)
97
174
  if (!doc) continue
175
+ // 旧索引里已经塞进去的注入块(标题生成 / system-reminder)照样跳过:
176
+ // 不重建索引也能挡住,避免 recall 把宿主的话当他的话翻出来。
177
+ if (!stripHarnessNoise(String(doc.text ?? ''))) continue
178
+ if (isEcho(doc)) continue
98
179
  const days = Math.max(0, Math.floor((now - Number(doc.ts)) / 86_400_000))
99
180
  const recency = Math.exp(-days / 60)
100
181
  const v = s * Number(doc.importance ?? 0.5) * (0.7 + 0.3 * recency)
@@ -106,9 +187,12 @@ export function search(store, { query = '', k = 8, budget = 800, now = Date.now(
106
187
  let used = 0
107
188
  for (const it of items) {
108
189
  if (picked.length >= k) break
109
- if (used + it._len > budget && picked.length) continue
190
+ // 预算按"实际会进提示词的长度"算:正文只 clip 到 240 字,拿原始长度去占预算,
191
+ // 一条超长的誊抄/长文就能把后面所有记忆挤掉(2026-11-11 真机:整段对话誊抄吃掉 800 字)。
192
+ const cost = Math.min(it._len, 240)
193
+ if (used + cost > budget && picked.length) continue
110
194
  picked.push(it)
111
- used += it._len
195
+ used += cost
112
196
  }
113
197
  return {
114
198
  items: picked.map(({ _len, ...rest }) => rest),