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/AGENTS.md +1 -1
- package/CHANGELOG.md +24 -1
- package/README.md +14 -5
- package/THIRD-PARTY-NOTICES.md +12 -3
- package/assets/adult-fish.soul.json +2798 -840
- package/client.js +126 -13
- package/index.js +115 -9
- package/lib/attach.js +27 -48
- package/lib/bili.js +179 -4
- package/lib/biliqr.js +341 -0
- package/lib/console.js +194 -8
- package/lib/corpus-rules.js +50 -0
- package/lib/corpus.js +98 -0
- package/lib/derive.js +68 -4
- package/lib/format.js +59 -0
- package/lib/guard.js +7 -3
- package/lib/noise.js +68 -0
- package/lib/persona.js +21 -7
- package/lib/qr.js +450 -0
- package/lib/reflect.js +4 -1
- package/lib/search.js +94 -10
- package/lib/settings.js +6 -14
- package/lib/store.js +23 -2
- package/lib/tools.js +22 -8
- package/lib/version.js +1 -1
- package/package.json +2 -2
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
|
|
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
|
-
//
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
|
90
|
-
|
|
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 =
|
|
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
|
-
|
|
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 +=
|
|
195
|
+
used += cost
|
|
112
196
|
}
|
|
113
197
|
return {
|
|
114
198
|
items: picked.map(({ _len, ...rest }) => rest),
|