dsh-deepmoyu 2.3.0 → 2.4.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.
Files changed (89) hide show
  1. package/AGENTS.md +91 -153
  2. package/CHANGELOG.md +27 -0
  3. package/CONTRIBUTING.md +51 -0
  4. package/CONTRIBUTORS.md +3 -7
  5. package/NOTICE.md +3 -3
  6. package/README.md +141 -81
  7. package/SECURITY.md +21 -0
  8. package/assets/screenshots/memory.png +0 -0
  9. package/assets/screenshots/menu.png +0 -0
  10. package/assets/screenshots/workspace.png +0 -0
  11. package/client.js +1348 -441
  12. package/cordis.patch.yml +1 -1
  13. package/docs/ASSETS.md +25 -0
  14. package/docs/DEPLOYMENT.md +102 -0
  15. package/docs/PRIVACY.md +32 -0
  16. package/docs/RELEASE_AUDIT.md +70 -0
  17. package/docs/RELEASING.md +94 -0
  18. package/docs/TEST_MATRIX.md +77 -0
  19. package/docs/assets-permissions.json +20 -0
  20. package/docs/tool-evidence.json +196 -0
  21. package/index.js +953 -270
  22. package/lib/asr.py +41 -35
  23. package/lib/background-output.mjs +64 -17
  24. package/lib/background-tools.mjs +1 -0
  25. package/lib/companion.mjs +43 -0
  26. package/lib/config-policy.mjs +20 -0
  27. package/lib/favops.py +9 -2
  28. package/lib/ids.mjs +41 -0
  29. package/lib/login-session.mjs +34 -0
  30. package/lib/login.mjs +5 -4
  31. package/lib/mcp.mjs +3 -2
  32. package/lib/memory-center.mjs +74 -0
  33. package/lib/mode.mjs +6 -7
  34. package/lib/model-relay.mjs +5 -4
  35. package/lib/open-directory.mjs +93 -0
  36. package/lib/pace.mjs +92 -12
  37. package/lib/persona.mjs +46 -0
  38. package/lib/product-status.mjs +123 -0
  39. package/lib/prompts.mjs +40 -5
  40. package/lib/pv-production.mjs +280 -0
  41. package/lib/pv-session.mjs +316 -0
  42. package/lib/pv.mjs +398 -306
  43. package/lib/pv.py +84 -22
  44. package/lib/pv_cover.py +108 -26
  45. package/lib/pv_editorial.py +336 -0
  46. package/lib/pv_verify.py +267 -0
  47. package/lib/python-env.mjs +61 -0
  48. package/lib/reply.mjs +10 -3
  49. package/lib/run-settings.mjs +6 -0
  50. package/lib/sticker-automation.mjs +5 -4
  51. package/lib/sticker.mjs +135 -25
  52. package/lib/tool-scope.mjs +65 -0
  53. package/lib/uvfetch.mjs +165 -153
  54. package/lib/verify.mjs +111 -127
  55. package/marketplace/pyyxzi__dsh-deepmoyu.yml +7 -0
  56. package/package.json +23 -12
  57. package/presets/deepmoyu.patch.yml +1 -1
  58. package/prompts/duty.md +4 -3
  59. package/screenshots.json +5 -0
  60. package/selfcheck.mjs +21 -6
  61. package/vendor/bilibili-mcp/requirements.in +9 -0
  62. package/vendor/bilibili-mcp/requirements.txt +1936 -11
  63. package/vendor/bilibili-mcp/runtime_guard.py +35 -10
  64. package/lib/__pycache__/pv.cpython-310.pyc +0 -0
  65. package/lib/__pycache__/pv_cover.cpython-310.pyc +0 -0
  66. package/tests/automation-routes.test.mjs +0 -183
  67. package/tests/automation.test.mjs +0 -79
  68. package/tests/background-tools.test.mjs +0 -182
  69. package/tests/background-window.test.mjs +0 -75
  70. package/tests/dup-cross-channel.test.mjs +0 -158
  71. package/tests/manual-browsing.test.mjs +0 -671
  72. package/tests/mcp-restart.test.mjs +0 -131
  73. package/tests/memory-chain.test.mjs +0 -413
  74. package/tests/model-relay.test.mjs +0 -116
  75. package/tests/persona.test.mjs +0 -67
  76. package/tests/pet-run-routes.test.mjs +0 -127
  77. package/tests/pet-run-settings.test.mjs +0 -38
  78. package/tests/pet-session.test.mjs +0 -125
  79. package/tests/pv-stage1.test.mjs +0 -203
  80. package/tests/sticker-routes.test.mjs +0 -141
  81. package/tests/sticker-upload-routes.test.mjs +0 -97
  82. package/tests/sticker-upload.test.mjs +0 -122
  83. package/tests/sticker.test.mjs +0 -169
  84. package/tests/test_guard_cross_channel.py +0 -141
  85. package/tests/test_pv_cover.py +0 -95
  86. package/tests/test_pv_stage1.py +0 -250
  87. package/tests/test_reply_pictures.py +0 -255
  88. package/tests/token-ledger.test.mjs +0 -78
  89. package/tests/verify_pv_sample.py +0 -91
@@ -1,131 +0,0 @@
1
- /**
2
- * ⭐ 「同一条评论被回了两条」的**最后一根刺**(2026-10-25):
3
- *
4
- * 判重代码早就改好了,真机上却照样重复 —— 因为那个 Python MCP 是**长命子进程**,
5
- * Python 不热加载模块,`runtime_guard.py` 改了它也不知道,一直用老代码里那本
6
- * 不认识「跨通道」的账去判重。
7
- *
8
- * 于是这里钉死两件事,缺一条修复就白改:
9
- * ① `McpClient.stop()` 必须把 `starting` 清干净
10
- * (否则下一次 call 会复用那个已失效的启动过程,永远起不来新进程);
11
- * ② 脚本(mcp_server.py / runtime_guard.py)一改,宿主就必须起一个新的子进程。
12
- *
13
- * 计数方式:假 server 每被启动一次就往 countFile 追加一个字符 ⇒ 「到底重起没重起」可数。
14
- */
15
- import { test } from 'node:test'
16
- import assert from 'node:assert/strict'
17
- import { spawnSync } from 'node:child_process'
18
- import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'
19
- import { tmpdir } from 'node:os'
20
- import { join, resolve } from 'node:path'
21
- import { pathToFileURL } from 'node:url'
22
-
23
- import { McpClient } from '../lib/mcp.mjs'
24
-
25
- const root = resolve(import.meta.dirname, '../../..')
26
- const mcpUrl = pathToFileURL(join(root, 'plugin/dsh-dafeiyu/lib/mcp.mjs')).href
27
-
28
- /** 最小 stdio MCP server:启动时往 COUNT_FILE 追加一个字符,好数「起了几次」 */
29
- const FAKE_SERVER = `
30
- import { createInterface } from 'node:readline'
31
- import { appendFileSync } from 'node:fs'
32
- appendFileSync(process.env.COUNT_FILE, 'x')
33
- const rl = createInterface({ input: process.stdin })
34
- rl.on('line', (line) => {
35
- let msg
36
- try { msg = JSON.parse(line) } catch { return }
37
- if (msg.id === undefined) return
38
- const result = msg.method === 'initialize'
39
- ? { protocolVersion: '2024-11-05', capabilities: {}, serverInfo: { name: 'fake', version: '0' } }
40
- : { content: [{ type: 'text', text: JSON.stringify({ ok: true }) }] }
41
- process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\\n')
42
- })
43
- `
44
-
45
- function runNode(source) {
46
- const r = spawnSync(process.execPath, ['--input-type=module', '-e', source], {
47
- encoding: 'utf8',
48
- timeout: 60000,
49
- })
50
- assert.equal(r.status, 0, `子进程失败:\n${r.stdout}\n${r.stderr}`)
51
- return r.stdout
52
- }
53
-
54
- test('stop() 会把 starting 清掉(否则再也不会起新子进程)', () => {
55
- const c = new McpClient({ command: process.execPath, args: ['-e', '0'], cwd: process.cwd(), log: () => {} })
56
- c.starting = Promise.resolve() // 模拟「上一次启动留下的那个 Promise」
57
- c.proc = { kill() {} } // 而且进程已经没了
58
- c.stop()
59
- assert.equal(c.starting, null, 'stop() 没清 starting ⇒ 下一次 call() 会复用已失效的启动过程')
60
- assert.equal(c.running, false)
61
- })
62
-
63
- test('脚本指纹一变,宿主就重起子进程(判重代码改完立刻生效)', () => {
64
- const dir = mkdtempSync(join(tmpdir(), 'mcp-restart-'))
65
- try {
66
- const stateDir = join(dir, 'state')
67
- mkdirSync(stateDir, { recursive: true })
68
- const fake = join(stateDir, 'fake-server.mjs')
69
- const count = join(dir, 'starts.txt')
70
- writeFileSync(fake, FAKE_SERVER, 'utf8')
71
- // 这两个是**被 stat 的指纹源**(真宿主 stat 的就是它们),内容与假 server 无关
72
- writeFileSync(join(stateDir, 'mcp_server.py'), '# v1\n', 'utf8')
73
- writeFileSync(join(stateDir, 'runtime_guard.py'), '# v1\n', 'utf8')
74
- writeFileSync(count, '', 'utf8')
75
-
76
- // 把 index.js 里的 mcpStampNow / ensureMcp 语义原样复刻(真宿主不许 import ⇒ 只能这样测)
77
- const source = `
78
- import { McpClient } from ${JSON.stringify(mcpUrl)}
79
- import { statSync, writeFileSync, readFileSync } from 'node:fs'
80
- import { join } from 'node:path'
81
- const stateDir = ${JSON.stringify(stateDir)}
82
- const countFile = ${JSON.stringify(count)}
83
- const fake = ${JSON.stringify(fake)}
84
- const scripts = () => [join(stateDir, 'mcp_server.py'), join(stateDir, 'runtime_guard.py')]
85
- const stampNow = () => scripts()
86
- .map((f) => { try { const s = statSync(f); return s.size + ':' + s.mtimeMs } catch { return '-' } })
87
- .join('|')
88
- const starts = () => readFileSync(countFile, 'utf8').length
89
- let mcp = null
90
- let key = ''
91
- const ensure = () => {
92
- const stamp = stampNow()
93
- const keyNow = 'node|' + join(stateDir, 'mcp_server.py') + '#' + stamp
94
- if (mcp && mcp.running && key === keyNow) return mcp
95
- if (mcp) { mcp.stop(); mcp = null }
96
- key = keyNow
97
- mcp = new McpClient({
98
- command: process.execPath, args: [fake], cwd: stateDir,
99
- env: { COUNT_FILE: countFile }, log: () => {},
100
- })
101
- return mcp
102
- }
103
- const out = []
104
- // ① 连两次、指纹没变:只该起 1 个进程
105
- await ensure().call('ping', {})
106
- await ensure().call('ping', {})
107
- out.push(starts())
108
- // ② 改判重脚本:下一次调用必须重起
109
- writeFileSync(join(stateDir, 'runtime_guard.py'), '# v2 跨通道判重\\n', 'utf8')
110
- await new Promise((r) => setTimeout(r, 30))
111
- await ensure().call('ping', {})
112
- out.push(starts())
113
- // ③ 再连:指纹没变,不该再起
114
- await ensure().call('ping', {})
115
- out.push(starts())
116
- // ④ 收摊:子进程必须真的退掉(stdio 关掉 —— 它靠 stdin 结束自然退出),
117
- // 否则这个脚本会一直挂着、把测试拖到超时
118
- ensure().stop()
119
- process.stdout.write('RESULT:' + JSON.stringify(out))
120
- `
121
- const out = runNode(source)
122
- const m = out.match(/RESULT:(\[.*?\])/s)
123
- assert.ok(m, `没拿到结果:${out}`)
124
- const [a, b, c] = JSON.parse(m[1])
125
- assert.equal(a, 1, `同样的脚本连两次只该起 1 个子进程,实际起了 ${a} 个`)
126
- assert.equal(b, 2, `脚本改了却没重起子进程(还是 ${b} 个)—— 真机上判重就会一直是旧代码`)
127
- assert.equal(c, 2, `指纹没变却又多起了进程(${c} 个)`)
128
- } finally {
129
- rmSync(dir, { recursive: true, force: true })
130
- }
131
- })
@@ -1,413 +0,0 @@
1
- import test from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, existsSync, statSync, utimesSync, readdirSync, unlinkSync, openSync, closeSync, truncateSync, symlinkSync } from 'node:fs'
4
- import { tmpdir } from 'node:os'
5
- import { join, resolve, dirname } from 'node:path'
6
- import { fileURLToPath } from 'node:url'
7
- import { pathToFileURL } from 'node:url'
8
- import { spawn } from 'node:child_process'
9
- import { createHash } from 'node:crypto'
10
- import { atomicWriteFileSync, readJsonFileSync, withFileLockSync } from '../lib/record-storage.mjs'
11
- import { redactRecordText, sanitizeRecord } from '../lib/record-privacy.mjs'
12
- import { appendActivityRecord, importActivityFile, importArchivedActivityChunk, rotateActivityLog, readActivityTail, captureMemorySnapshot, readRecordEvents, appendLearningRecord } from '../lib/records.mjs'
13
- import { mirrorOnce, readMirrorManifest } from '../lib/mirror.mjs'
14
- import { learnAdd, learnDecide, learnForget, learnInject, learnStats, learnList, LEARN_PENDING_MAX, LEARN_APPROVED_MAX } from '../lib/learn.mjs'
15
- import { seenRemember, seenRememberMany, markSeen, seenStats, bvidOf } from '../lib/ledger.mjs'
16
- import { tasteRemember, tasteApplyLikes, tasteTop, tasteMirror, tasteStats, tasteDigest } from '../lib/taste.mjs'
17
-
18
- const temp = () => mkdtempSync(join(tmpdir(), 'dafeiyu-memory-test-'))
19
- /** 插件根目录(本文件在 <plugin>/tests/ 下)—— 测试从哪个 cwd 跑都能找到模块 */
20
- const PLUGIN_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..')
21
- const write = (dir, name, content) => writeFileSync(join(dir, name), content, 'utf8')
22
- const row = (uid, text = '读字幕并记录选择理由') => ({ uid, t: 1792540800000, kind: 'read', text })
23
- const digest = (text) => createHash('sha256').update(text).digest('hex')
24
-
25
- // All fixtures are synthetic. No runtime home, B站 account, network, or host restart is involved.
26
- test('atomic writes preserve unrelated files and refuse corrupted JSON rather than replacing it', () => {
27
- const dir = temp(); const file = join(dir, 'state.json')
28
- atomicWriteFileSync(file, '{"n":1}')
29
- assert.deepEqual(readJsonFileSync(file, {}), { n: 1 })
30
- write(dir, 'state.json', '{broken')
31
- assert.throws(() => readJsonFileSync(file, {}), SyntaxError)
32
- assert.equal(readFileSync(file, 'utf8'), '{broken')
33
- assert.throws(() => readJsonFileSync(file, {}, { maxBytes: 2 }), /RECORD_FILE_TOO_LARGE/)
34
- assert.deepEqual(readJsonFileSync(join(dir, 'missing.json'), { ok: true }), { ok: true })
35
- assert.equal(readdirSync(dir).filter((s) => s.endsWith('.tmp')).length, 0)
36
- })
37
-
38
- test('active locks fail closed; dead owners are recoverable and exceptions release locks', () => {
39
- const dir = temp(); const file = join(dir, 'transaction')
40
- write(dir, 'transaction.lock', JSON.stringify({ pid: process.pid, token: 'owner' }))
41
- assert.throws(() => withFileLockSync(file, () => assert.fail('stolen lock'), { timeoutMs: 20 }), /RECORD_LOCK_BUSY/)
42
- assert.equal(JSON.parse(readFileSync(file + '.lock', 'utf8')).token, 'owner')
43
- write(dir, 'transaction.lock', JSON.stringify({ pid: 2147483647, token: 'dead' }))
44
- assert.equal(withFileLockSync(file, () => 42), 42)
45
- assert.throws(() => withFileLockSync(file, () => { throw new Error('sentinel') }), /sentinel/)
46
- assert.equal(existsSync(file + '.lock'), false)
47
- })
48
-
49
- test('cross-process read-modify-write transactions do not lose increments', async () => {
50
- const dir = temp(); const file = join(dir, 'counter.json')
51
- atomicWriteFileSync(file, '{"n":0}')
52
- const module = pathToFileURL(join(PLUGIN_DIR, 'lib', 'record-storage.mjs')).href
53
- const source = `import {withFileLockSync,readJsonFileSync,atomicWriteFileSync} from ${JSON.stringify(module)}; const file=${JSON.stringify(file)}; for(let i=0;i<15;i++) withFileLockSync(file,()=>{ const s=readJsonFileSync(file,{}); s.n++; atomicWriteFileSync(file,JSON.stringify(s)); },{timeoutMs:10000});`
54
- const jobs = Array.from({ length: 4 }, () => new Promise((resolveJob, reject) => {
55
- const child = spawn(process.execPath, ['--input-type=module', '-e', source], { stdio: 'inherit' })
56
- child.once('error', reject); child.once('exit', (code) => code === 0 ? resolveJob() : reject(new Error(`child exit ${code}`)))
57
- }))
58
- await Promise.all(jobs)
59
- assert.equal(readJsonFileSync(file, {}).n, 60)
60
- })
61
-
62
- test('privacy boundary removes credential fields, nested secrets, contacts and bearer tokens', () => {
63
- const data = sanitizeRecord({ cookie: 'session-fixture', nested: { api_key: 'key-fixture' }, phone: '13800138000', text: '邮箱 demo@example.org 电话13800138000 Authorization: Bearer abcdefghijklmnopqrstuvwxyz' })
64
- const json = JSON.stringify(data)
65
- for (const secret of ['session-fixture', 'key-fixture', '13800138000', 'demo@example.org', 'abcdefghijklmnopqrstuvwxyz']) assert.ok(!json.includes(secret))
66
- assert.ok(redactRecordText('SESSDATA=fixture-secret;').includes('[已隐去]'))
67
- })
68
-
69
- test('append, source import and projection re-import share stable identity; uid conflict is explicit', () => {
70
- const dir = temp()
71
- const a = appendActivityRecord(dir, row('fixture-1'))
72
- assert.equal(a.duplicate, false)
73
- assert.equal(appendActivityRecord(dir, row('fixture-1')).duplicate, true)
74
- const conflict = appendActivityRecord(dir, row('fixture-1', '另一件确实不同的事'))
75
- assert.equal(conflict.conflict, true)
76
- const r = importActivityFile(dir, join(dir, 'activity.jsonl'))
77
- assert.equal(r.imported, 0); assert.equal(r.duplicates, 1)
78
- assert.equal(readRecordEvents(dir, { type: 'activity' }).length, 1)
79
- assert.equal(readActivityTail(dir).rows.length, 1)
80
- })
81
-
82
- test('legacy rows without uid remain idempotent after normalized projection reuse', () => {
83
- const dir = temp(); const source = join(dir, 'old.jsonl')
84
- const legacy = { t: 1792540800000, kind: 'read', text: '旧版真实结构的合成样本' }
85
- write(dir, 'old.jsonl', JSON.stringify(legacy) + '\n')
86
- assert.equal(importActivityFile(dir, source).imported, 1)
87
- const ev = readRecordEvents(dir, { type: 'activity' })[0]
88
- write(dir, 'projected.jsonl', JSON.stringify(ev.data) + '\n')
89
- const again = importActivityFile(dir, join(dir, 'projected.jsonl'))
90
- assert.equal(again.imported, 0); assert.equal(again.duplicates, 1)
91
- })
92
-
93
- test('resumable imports bound I/O, count damaged rows and wait for a partial line', () => {
94
- const dir = temp(); const file = join(dir, 'legacy.jsonl')
95
- const good = Array.from({ length: 25 }, (_, i) => JSON.stringify(row(`old-${i}`, '合成记录'.repeat(20))))
96
- write(dir, 'legacy.jsonl', good.join('\n') + '\nnot-json\n' + JSON.stringify(row('partial')).slice(0, 30))
97
- let count = 0; let invalid = 0; let attempts = 0; let result
98
- do { result = importActivityFile(dir, file, { maxBytes: 1024 }); count += result.imported; invalid += result.invalid; assert.ok((result.bytesRead || 0) <= 1024); attempts++ } while (result.ok && result.more && attempts < 100)
99
- assert.equal(count, 25); assert.equal(invalid, 1); assert.equal(result.error, 'ACTIVITY_PARTIAL_LINE')
100
- assert.equal(readRecordEvents(dir, { type: 'activity', limit: 100 }).length, 25)
101
- })
102
-
103
- test('corrupt import cursor fails closed and the source survives untouched', () => {
104
- const dir = temp(); const file = join(dir, 'legacy.jsonl')
105
- write(dir, 'legacy.jsonl', JSON.stringify(row('cursor')) + '\n')
106
- importActivityFile(dir, file)
107
- const folder = join(dir, 'records', 'imports'); const cursor = join(folder, readdirSync(folder)[0])
108
- writeFileSync(cursor, '{broken')
109
- const before = digest(readFileSync(file))
110
- assert.throws(() => importActivityFile(dir, file), SyntaxError)
111
- assert.equal(digest(readFileSync(file)), before)
112
- })
113
-
114
- test('clear and repeated rotation keep unique archives and immutable replayable events', () => {
115
- const dir = temp(); appendActivityRecord(dir, row('before-clear'))
116
- const one = rotateActivityLog(dir, { force: true }); assert.equal(one.rotated, true)
117
- appendActivityRecord(dir, row('after-clear'))
118
- const two = rotateActivityLog(dir, { force: true }); assert.notEqual(one.archive, two.archive)
119
- assert.ok(existsSync(one.archive)); assert.ok(existsSync(two.archive))
120
- assert.equal(readRecordEvents(dir, { type: 'activity' }).length, 2)
121
- assert.equal(readFileSync(join(dir, 'activity.jsonl'), 'utf8'), '')
122
- })
123
-
124
- test('private activity keeps only outcome/identity and strips nested inbox bodies', () => {
125
- const dir = temp()
126
- const r = appendActivityRecord(dir, { ...row('dm-1', '给人回私信,含隐私样本'), tool: 'bili_dm_reply', payload: { content: 'PRIVATE-FIXTURE', email: 'demo@example.org' }, recipient: 'private-person' })
127
- const json = JSON.stringify(r)
128
- assert.ok(!json.includes('PRIVATE-FIXTURE')); assert.ok(!json.includes('private-person')); assert.ok(!json.includes('demo@example.org'))
129
- assert.equal(importActivityFile(dir, join(dir, 'activity.jsonl')).duplicates, 1)
130
- })
131
-
132
- test('large log tail does not read all bytes and deduplicates legacy entries', () => {
133
- const dir = temp(); const file = join(dir, 'activity.jsonl')
134
- const fd = openSync(file, 'w'); closeSync(fd); truncateSync(file, 12 * 1024 * 1024)
135
- writeFileSync(file, '\n' + JSON.stringify(row('tail')) + '\n' + JSON.stringify(row('tail')) + '\n', { flag: 'a' })
136
- const tail = readActivityTail(dir, { maxBytes: 2048 })
137
- assert.ok(tail.bytesRead <= 2048); assert.equal(tail.truncated, true); assert.equal(tail.rows.length, 1)
138
- })
139
-
140
- test('mode scoped memory snapshots preserve a reversible source chain and redact credentials', () => {
141
- const dir = temp()
142
- assert.equal(captureMemorySnapshot(dir, 'soul.md', 'not stored').reason, 'mode_required')
143
- assert.equal(existsSync(join(dir, 'records')), false)
144
- const a = captureMemorySnapshot(dir, 'soul.md', '我的想法 SESSDATA=fixture-secret;', { mode: 'dafeiyu', operation: 'write' })
145
- const duplicate = captureMemorySnapshot(dir, 'soul.md', '我的想法 SESSDATA=fixture-secret;', { mode: 'dafeiyu' })
146
- assert.equal(duplicate.duplicate, true); assert.equal(a.eventId, duplicate.eventId)
147
- const b = captureMemorySnapshot(dir, 'soul.md', '第二个想法', { mode: 'dafeiyu' })
148
- assert.equal(b.event.data.previousEventId, a.eventId)
149
- assert.ok(!readFileSync(a.path, 'utf8').includes('fixture-secret'))
150
- const c = captureMemorySnapshot(dir, 'soul.md', '我的想法 SESSDATA=fixture-secret;', { mode: 'dafeiyu', operation: 'restore' })
151
- assert.equal(c.event.data.previousEventId, b.eventId); assert.notEqual(c.eventId, a.eventId)
152
- assert.equal(captureMemorySnapshot(dir, '../credential', '', { mode: 'dafeiyu' }).error, 'MEMORY_NAME_NOT_ALLOWED')
153
- })
154
-
155
- test('learning requires explicit valid approval and injects only inside mode', () => {
156
- const a = learnAdd({}, { kind: 'style', text: '先说具体哪一下触动自己', from: 'BV1234567890', state: 'approved' })
157
- assert.equal(a.ledger.items[a.id].state, 'pending')
158
- assert.equal(learnInject(a.ledger, { mode: 'dafeiyu' }).used, 0)
159
- assert.equal(learnDecide(a.ledger, a.id, 'surprise').ok, false)
160
- const b = learnDecide(a.ledger, a.id, 'approve', '合成测试审批')
161
- assert.equal(learnInject(b.ledger).reason, 'mode_required')
162
- const injected = learnInject(b.ledger, { mode: 'dafeiyu', budget: 1200 })
163
- assert.ok(injected.text.includes(a.id)); assert.equal(injected.used, injected.text.length)
164
- assert.equal(learnInject(b.ledger, { mode: 'dafeiyu', budget: 0 }).used, 0)
165
- assert.equal(learnInject(b.ledger, { mode: 'dafeiyu', budget: 400.9 }).used <= 400, true)
166
- })
167
-
168
- test('duplicate learning merges provenance without promoting rejected content; revoke is audited', () => {
169
- const a = learnAdd({}, { kind: 'jargon', text: '解释词义以后再决定要不要学', from: '来源A' })
170
- const b = learnDecide(a.ledger, a.id, 'reject')
171
- const c = learnAdd(b.ledger, { kind: 'jargon', text: '解释词义以后再决定要不要学', from: '来源B' })
172
- assert.equal(c.added, false); assert.equal(c.ledger.items[a.id].state, 'rejected')
173
- assert.deepEqual(c.ledger.items[a.id].sources, ['来源A', '来源B'])
174
- const d = learnForget(c.ledger, { from: '来源B' })
175
- assert.equal(d.removed, 1); assert.equal(learnStats(d.ledger).total, 0)
176
- assert.equal(d.auditEvent.action, 'forget'); assert.equal(d.auditEvent.revoked[0].id, a.id)
177
- const dir = temp(); assert.equal(appendLearningRecord(dir, d.auditEvent).duplicate, false)
178
- assert.equal(appendLearningRecord(dir, d.auditEvent).duplicate, true)
179
- assert.equal(readRecordEvents(dir, { type: 'learning' }).length, 1)
180
- })
181
-
182
- test('learning capacity does not delete pending or previously approved evidence', () => {
183
- let book = {}
184
- for (let i = 0; i < LEARN_PENDING_MAX; i++) book = learnAdd(book, { text: `待审合成记录${i}` }).ledger
185
- const overflow = learnAdd(book, { text: '满队列以后的一条' })
186
- assert.equal(overflow.added, false); assert.equal(learnStats(overflow.ledger).pending, LEARN_PENDING_MAX)
187
- const items = Object.fromEntries(Array.from({ length: LEARN_APPROVED_MAX }, (_, i) => [`style:a${i}`, { id: `style:a${i}`, kind: 'style', text: `已批准${i}`, state: 'approved' }]))
188
- const a = learnAdd({ items }, { kind: 'style', text: '另一条待审' })
189
- assert.equal(learnDecide(a.ledger, a.id, 'approve').ok, false)
190
- assert.equal(learnStats(a.ledger).approved, LEARN_APPROVED_MAX)
191
- })
192
-
193
- test('learning rendering is bounded, source aware, privacy safe and does not mutate its input', () => {
194
- const a = learnAdd({}, { text: 'SESSDATA=fixture-secret; 别抄句子', example: '联系 demo@example.org', from: '13800138000' })
195
- const before = JSON.stringify(a.ledger)
196
- const b = learnDecide(a.ledger, a.id, 'approve')
197
- assert.equal(JSON.stringify(a.ledger), before)
198
- const page = learnList(b.ledger, { state: 'approved' })
199
- const injected = learnInject(b.ledger, { mode: 'dafeiyu' })
200
- for (const secret of ['fixture-secret', 'demo@example.org', '13800138000']) assert.ok(!(JSON.stringify(page) + injected.text).includes(secret))
201
- })
202
-
203
- test('ledger rejects longer BV lookalikes, corrupt keys and remains immutable', () => {
204
- assert.equal(bvidOf('BV1234567890EXTRA'), '')
205
- assert.equal(bvidOf('https://www.bilibili.com/video/BV1234567890?t=2'), 'BV1234567890')
206
- const input = { junk: { at: 'bad' }, BV1234567890: { at: '2026-01-01T00:00:00.000Z', note: '旧备注' } }
207
- const before = JSON.stringify(input)
208
- const next = seenRemember(input, 'BV1234567890', { at: '2026-10-21T00:00:00Z', note: 'SESSDATA=fixture-secret;', source: 'subtitle' })
209
- assert.equal(next.BV1234567890.at, input.BV1234567890.at)
210
- assert.ok(!next.BV1234567890.note.includes('fixture-secret'))
211
- assert.equal(seenStats(next).total, 1); assert.equal(JSON.stringify(input), before)
212
- assert.equal(markSeen([{ bvid: 'BV1234567890' }], next).seenCount, 1)
213
- assert.equal(seenStats(seenRememberMany({}, ['BV1234567890', 'BV1234567890'])).total, 1)
214
- })
215
-
216
- test('taste preserves 19 digit strings, refuses rounded numeric ids and merges all notification maxima', () => {
217
- const id = '3198765432109876543'
218
- const a = tasteRemember({}, id, { text: '本鱼在03:20听见那句话', likes: 7 })
219
- assert.ok(a[id]); assert.equal(Object.keys(tasteRemember({}, Number(id), { text: 'rounded' })).length, 0)
220
- const before = JSON.stringify(a)
221
- const b = tasteApplyLikes(a, [{ item_id: id, count: 2 }, { item_id: id, count: 19 }, { item_id: id, count: 0 }])
222
- assert.equal(b.ledger[id].likes, 19); assert.equal(b.matched, 1); assert.equal(b.updated, 1)
223
- assert.equal(JSON.stringify(a), before)
224
- assert.equal(tasteApplyLikes(b.ledger, [{ item_id: id, count: 0 }]).ledger[id].likes, 19)
225
- assert.equal(tasteApplyLikes(b.ledger, [{ item_id: id }]).matched, 0)
226
- })
227
-
228
- test('taste output does not reproduce secrets from current or legacy comment text', () => {
229
- const a = tasteRemember({}, '99', { text: 'SESSDATA=fixture-secret; demo@example.org', likes: 2 })
230
- const legacy = { ...a, '100': { text: 'SESSDATA=old-fixture-secret;', likes: 3, at: '2026-10-22' } }
231
- const text = JSON.stringify(tasteTop(legacy)) + JSON.stringify(tasteMirror(legacy))
232
- assert.ok(!text.includes('fixture-secret')); assert.ok(!text.includes('demo@example.org'))
233
- })
234
-
235
- test('mirror rejects credential/traversal names and self targets, preserves originals', () => {
236
- const from = temp(); const to = temp()
237
- write(from, 'bili_credential.json', '{"SESSDATA":"fixture-secret"}')
238
- write(from, 'soul.md', '我的原始想法 SESSDATA=fixture-secret;')
239
- const original = digest(readFileSync(join(from, 'soul.md')))
240
- const r = mirrorOnce(from, to, { names: ['soul.md', 'bili_credential.json', '../escape'] })
241
- assert.equal(r.ok, false); assert.equal(r.copied.length, 1); assert.equal(r.failed.length, 2)
242
- assert.equal(existsSync(join(to, 'bili_credential.json')), false)
243
- assert.ok(!readFileSync(join(to, 'soul.md'), 'utf8').includes('fixture-secret'))
244
- assert.equal(digest(readFileSync(join(from, 'soul.md'))), original)
245
- assert.equal(mirrorOnce(from, from).failed[0].error, 'MIRROR_SAME_DIRECTORY')
246
- })
247
-
248
- test('mirror detects same-size same-mtime edits and repairs a modified target', () => {
249
- const from = temp(); const to = temp(); write(from, 'voice.md', '甲甲甲')
250
- assert.equal(mirrorOnce(from, to, { names: ['voice.md'] }).copied.length, 1)
251
- const first = statSync(join(from, 'voice.md'))
252
- write(from, 'voice.md', '乙乙乙'); utimesSync(join(from, 'voice.md'), first.atime, first.mtime)
253
- assert.equal(mirrorOnce(from, to, { names: ['voice.md'] }).copied.length, 1)
254
- assert.equal(readFileSync(join(to, 'voice.md'), 'utf8'), '乙乙乙')
255
- assert.equal(mirrorOnce(from, to, { names: ['voice.md'] }).skipped.length, 1)
256
- write(to, 'voice.md', '被人改过')
257
- assert.equal(mirrorOnce(from, to, { names: ['voice.md'] }).copied.length, 1)
258
- assert.ok(readMirrorManifest(to).files['voice.md'].sha256)
259
- })
260
-
261
- test('mirror corrupted/oversized source fails visibly and does not destroy a prior valid mirror', () => {
262
- const from = temp(); const to = temp(); write(from, 'state.json', '{"seenVideos":{}}')
263
- assert.equal(mirrorOnce(from, to, { names: ['state.json'] }).ok, true)
264
- const before = readFileSync(join(to, 'state.json'), 'utf8')
265
- write(from, 'state.json', '{broken')
266
- assert.equal(mirrorOnce(from, to, { names: ['state.json'] }).ok, false)
267
- assert.equal(readFileSync(join(to, 'state.json'), 'utf8'), before)
268
- const fd = openSync(join(from, 'soul.md'), 'w'); closeSync(fd); truncateSync(join(from, 'soul.md'), 3 * 1024 * 1024)
269
- assert.equal(mirrorOnce(from, to, { names: ['soul.md'] }).failed[0].error, 'MIRROR_SOURCE_TOO_LARGE')
270
- })
271
-
272
- test('mirror state preserves over 500 valid ledger keys while excluding nested credential data', () => {
273
- const from = temp(); const to = temp()
274
- const seenVideos = Object.fromEntries(Array.from({ length: 800 }, (_, i) => [`BV${String(i).padStart(10, '0')}`, { note: '已看' }]))
275
- write(from, 'state.json', JSON.stringify({ seenVideos, credential: { SESSDATA: 'fixture-secret' } }))
276
- assert.equal(mirrorOnce(from, to, { names: ['state.json'] }).ok, true)
277
- const output = JSON.parse(readFileSync(join(to, 'state.json'), 'utf8'))
278
- assert.equal(Object.keys(output.seenVideos).length, 800)
279
- assert.ok(!JSON.stringify(output).includes('fixture-secret'))
280
- })
281
-
282
- test('async lock callbacks are rejected before execution and orphan recovery gates fail closed', () => {
283
- const dir = temp(); const file = join(dir, 'transaction'); let called = false
284
- assert.throws(() => withFileLockSync(file, async () => { called = true }), /RECORD_LOCK_CALLBACK_MUST_BE_SYNC/)
285
- assert.equal(called, false); assert.equal(existsSync(file + '.lock'), false)
286
- write(dir, 'transaction.lock', JSON.stringify({ pid: 2147483647, token: 'dead' }))
287
- write(dir, 'transaction.lock.recover', 'orphaned recovery must be investigated')
288
- assert.throws(() => withFileLockSync(file, () => {}, { timeoutMs: 0 }), /RECORD_LOCK_BUSY/)
289
- assert.equal(JSON.parse(readFileSync(file + '.lock', 'utf8')).token, 'dead')
290
- })
291
-
292
- test('concurrent activity writers reconcile one identity to one durable event and projection', async () => {
293
- const dir = temp()
294
- const module = pathToFileURL(join(PLUGIN_DIR, 'lib', 'records.mjs')).href
295
- const jobs = Array.from({ length: 3 }, (_, worker) => new Promise((resolveJob, reject) => {
296
- const source = `import {appendActivityRecord} from ${JSON.stringify(module)}; for(let i=0;i<6;i++){ const r=appendActivityRecord(${JSON.stringify(dir)},{uid:'concurrent-'+i,t:1792540800000,kind:'read',text:'同一事件的合成样本'},{timeoutMs:10000}); if(!r.ok) throw new Error(r.error); }`
297
- const child = spawn(process.execPath, ['--input-type=module', '-e', source], { stdio: 'inherit' })
298
- child.once('error', reject); child.once('exit', (code) => code === 0 ? resolveJob(worker) : reject(new Error(`child exit ${code}`)))
299
- }))
300
- await Promise.all(jobs)
301
- assert.equal(readRecordEvents(dir, { type: 'activity' }).length, 6)
302
- assert.equal(readFileSync(join(dir, 'activity.jsonl'), 'utf8').trim().split('\n').length, 6)
303
- })
304
-
305
- test('clear during legacy migration transfers the cursor and finishes the archive exactly once', () => {
306
- const dir = temp(); const file = join(dir, 'activity.jsonl')
307
- write(dir, 'activity.jsonl', Array.from({ length: 40 }, (_, i) => JSON.stringify(row(`clear-${i}`))).join('\n') + '\n')
308
- assert.equal(importActivityFile(dir, file, { maxRows: 3 }).imported, 3)
309
- const rotated = rotateActivityLog(dir, { force: true })
310
- assert.ok(existsSync(rotated.archive)); assert.equal(readFileSync(file, 'utf8'), '')
311
- let imported = 0; let duplicates = 0; let more = true; let rounds = 0
312
- while (more && rounds++ < 30) {
313
- const r = importArchivedActivityChunk(dir, { maxRows: 4, maxBytes: 1024 })
314
- assert.equal(r.ok, true); assert.ok((r.bytesRead || 0) <= 1024)
315
- imported += r.imported; duplicates += r.duplicates; more = r.more
316
- }
317
- assert.equal(more, false); assert.equal(imported, 37); assert.equal(duplicates, 0)
318
- assert.equal(readRecordEvents(dir, { type: 'activity', limit: 100 }).length, 40)
319
- assert.equal(importArchivedActivityChunk(dir).processed, false)
320
- })
321
-
322
- test('preexisting raw archives are discovered one day at a time, not scanned every idle turn', () => {
323
- const dir = temp()
324
- for (const [day, id] of [['2026-01-01', 'archived-old-1'], ['2026-01-02', 'archived-old-2']]) {
325
- const folder = join(dir, 'records', 'activity', day); mkdirSync(folder, { recursive: true })
326
- write(folder, 'old.jsonl', JSON.stringify(row(id)) + '\n')
327
- }
328
- const a = importArchivedActivityChunk(dir); const b = importArchivedActivityChunk(dir)
329
- assert.equal(a.imported, 1); assert.equal(a.more, true); assert.equal(b.imported, 1)
330
- const queue = readJsonFileSync(join(dir, 'records', 'archive-imports.json'), null)
331
- assert.equal(queue.discovery.position, 2); assert.ok(queue.discovery.nextScanAt > Date.now())
332
- assert.equal(importArchivedActivityChunk(dir).discovered, 0)
333
- assert.equal(readRecordEvents(dir, { type: 'activity' }).length, 2)
334
- })
335
-
336
- test('damaged immutable archive tails are reported once and do not block another archive', () => {
337
- const dir = temp(); const folder = join(dir, 'records', 'activity', '2026-01-01'); mkdirSync(folder, { recursive: true })
338
- write(folder, 'a.jsonl', JSON.stringify(row('bad-tail-valid-row')) + '\n{"unfinished":')
339
- write(folder, 'b.jsonl', JSON.stringify(row('after-bad-tail')) + '\n')
340
- const before = digest(readFileSync(join(folder, 'a.jsonl')))
341
- let errors = 0; let more = true; let rounds = 0
342
- while (more && rounds++ < 10) { const r = importArchivedActivityChunk(dir); if (r.error === 'ACTIVITY_PARTIAL_LINE') errors++; more = r.more }
343
- assert.equal(more, false); assert.equal(errors, 1)
344
- assert.equal(digest(readFileSync(join(folder, 'a.jsonl'))), before)
345
- assert.equal(readRecordEvents(dir, { type: 'activity' }).length, 2)
346
- assert.equal(importArchivedActivityChunk(dir).processed, false)
347
- })
348
-
349
- test('corrupt archive queue prevents rotation without moving or truncating the live source', () => {
350
- const dir = temp(); appendActivityRecord(dir, row('queue-failure'))
351
- const before = digest(readFileSync(join(dir, 'activity.jsonl')))
352
- write(join(dir, 'records'), 'archive-imports.json', '{broken')
353
- assert.throws(() => rotateActivityLog(dir, { force: true }), SyntaxError)
354
- assert.equal(digest(readFileSync(join(dir, 'activity.jsonl'))), before)
355
- assert.equal(readRecordEvents(dir, { type: 'activity' }).length, 1)
356
- })
357
-
358
- test('event retry repairs missing projection/index receipts and never overwrites damaged canonical events', () => {
359
- const dir = temp(); const a = appendActivityRecord(dir, row('receipt-repair'))
360
- unlinkSync(a.path + '.indexed')
361
- const projection = join(dir, 'records', 'projected', a.eventId.slice(0, 2), a.eventId + '.json')
362
- unlinkSync(projection)
363
- assert.equal(appendActivityRecord(dir, row('receipt-repair')).duplicate, true)
364
- assert.equal(readActivityTail(dir).rows.length, 1); assert.equal(readRecordEvents(dir).length, 1)
365
- assert.ok(existsSync(projection)); assert.ok(existsSync(a.path + '.indexed'))
366
- writeFileSync(a.path, '{broken')
367
- assert.throws(() => appendActivityRecord(dir, row('receipt-repair')), SyntaxError)
368
- assert.equal(readFileSync(a.path, 'utf8'), '{broken')
369
- })
370
-
371
- test('large revocation audits keep every tombstone instead of truncating at 500', () => {
372
- const items = Object.fromEntries(Array.from({ length: 650 }, (_, i) => [`style:test${i}`, { id: `style:test${i}`, kind: 'style', text: `旧合成记录${i}`, state: 'rejected', from: 'fixture' }]))
373
- const r = learnForget({ items }, { kind: 'style' }); assert.equal(r.removed, 650)
374
- const dir = temp(); const saved = appendLearningRecord(dir, r.auditEvent)
375
- assert.equal(saved.ok, true); assert.equal(saved.event.data.revoked.length, 650); assert.equal(saved.event.data.removed, 650)
376
- })
377
-
378
- test('invalid taste metadata and likes do not enter sample counts or fictional causal conclusions', () => {
379
- const book = { broken: { text: '本鱼', likes: 900 }, '1': { text: '', likes: 9 } }
380
- let valid = tasteRemember(book, '2', { text: '本鱼记下一句话', likes: 20 })
381
- valid = tasteRemember(valid, '3', { text: '本鱼记下另一句话', likes: 0 })
382
- valid = tasteRemember(valid, '4', { text: '本鱼还想试试', likes: 0 })
383
- assert.equal(tasteStats(valid).sample, 3)
384
- const out = tasteApplyLikes(valid, [{ item_id: '3', count: 1e308 }])
385
- assert.equal(out.ledger['3'].likes, 0)
386
- const digest = tasteDigest(out.ledger).lines.join('\n')
387
- assert.ok(!digest.includes('那是那条视频本身火'))
388
- assert.ok(!JSON.stringify(tasteStats(out.ledger)).includes('Infinity'))
389
- assert.ok(!tasteMirror(valid).lines.join('\n').includes('不点自己'))
390
- assert.equal(seenStats({ BV1234567890: { at: 'not-a-date' } }).lastAt, '')
391
- })
392
-
393
- test('importing a valid row against a damaged canonical event fails without advancing its cursor', () => {
394
- const dir = temp(); const a = appendActivityRecord(dir, row('canonical-damaged'))
395
- writeFileSync(a.path, '{broken-canonical')
396
- write(dir, 'legacy.jsonl', JSON.stringify(row('canonical-damaged')) + '\n')
397
- assert.throws(() => importActivityFile(dir, join(dir, 'legacy.jsonl')), SyntaxError)
398
- const cursors = join(dir, 'records', 'imports')
399
- assert.equal(existsSync(cursors), false)
400
- assert.equal(readFileSync(a.path, 'utf8'), '{broken-canonical')
401
- })
402
-
403
- test('memory snapshots refuse a missing or altered predecessor instead of fabricating a new chain', () => {
404
- const dir = temp(); const a = captureMemorySnapshot(dir, 'soul.md', '合成想法甲', { mode: 'dafeiyu' })
405
- const heads = readFileSync(join(dir, 'records', 'memory-heads.json'), 'utf8')
406
- const previous = JSON.parse(readFileSync(a.path, 'utf8')); previous.data.text = '被意外改动的合成文本'
407
- writeFileSync(a.path, JSON.stringify(previous))
408
- assert.throws(() => captureMemorySnapshot(dir, 'soul.md', '合成想法乙', { mode: 'dafeiyu' }), /MEMORY_EVENT_INVALID/)
409
- assert.equal(readFileSync(join(dir, 'records', 'memory-heads.json'), 'utf8'), heads)
410
- unlinkSync(a.path)
411
- assert.throws(() => captureMemorySnapshot(dir, 'soul.md', '合成想法甲', { mode: 'dafeiyu' }), /MEMORY_EVENT_INVALID/)
412
- assert.equal(readFileSync(join(dir, 'records', 'memory-heads.json'), 'utf8'), heads)
413
- })