solopup-memory 0.21.0-beta.3 → 0.22.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/COMPATIBILITY.md +8 -5
  3. package/DETECTION.md +139 -0
  4. package/DREAMING-DESIGN.md +167 -0
  5. package/README.en.md +33 -6
  6. package/README.md +38 -7
  7. package/RELEASE.md +26 -0
  8. package/SECURITY.md +7 -0
  9. package/context-history.mjs +20 -3
  10. package/detect.mjs +441 -0
  11. package/eval/bench-detect.mjs +107 -0
  12. package/eval/semantic/cases.json +387 -0
  13. package/eval/semantic-suite.mjs +156 -0
  14. package/index.mjs +82 -10
  15. package/managed-policy.mjs +3 -1
  16. package/package.json +90 -69
  17. package/scripts/demo-source-recall.mjs +123 -0
  18. package/scripts/detect.mjs +69 -0
  19. package/scripts/dream.mjs +205 -0
  20. package/scripts/probe-history.mjs +92 -0
  21. package/scripts/probe-load.mjs +1 -1
  22. package/scripts/probe-package.mjs +6 -2
  23. package/scripts/security-check.mjs +173 -0
  24. package/scripts/soak.mjs +219 -0
  25. package/scripts/telemetry.mjs +187 -0
  26. package/scripts/verify.mjs +3 -1
  27. package/{test-adversarial.mjs → test/test-adversarial.mjs} +5 -5
  28. package/{test-belief-revision.mjs → test/test-belief-revision.mjs} +7 -7
  29. package/{test-candidates.mjs → test/test-candidates.mjs} +4 -4
  30. package/{test-capture-diagnostics.mjs → test/test-capture-diagnostics.mjs} +6 -6
  31. package/{test-capture-field-lineage.mjs → test/test-capture-field-lineage.mjs} +4 -4
  32. package/{test-capture-inputs.mjs → test/test-capture-inputs.mjs} +8 -8
  33. package/{test-capture-jobs.mjs → test/test-capture-jobs.mjs} +5 -5
  34. package/{test-capture-projections.mjs → test/test-capture-projections.mjs} +12 -12
  35. package/{test-capture-revision.mjs → test/test-capture-revision.mjs} +6 -6
  36. package/{test-capture-submission.mjs → test/test-capture-submission.mjs} +3 -3
  37. package/{test-capture-validation-flow.mjs → test/test-capture-validation-flow.mjs} +5 -5
  38. package/{test-capture-validation-job.mjs → test/test-capture-validation-job.mjs} +5 -5
  39. package/{test-capture-validation-lineage.mjs → test/test-capture-validation-lineage.mjs} +2 -2
  40. package/{test-capture-validation.mjs → test/test-capture-validation.mjs} +2 -2
  41. package/{test-capture.mjs → test/test-capture.mjs} +20 -20
  42. package/{test-compaction-compat.mjs → test/test-compaction-compat.mjs} +5 -5
  43. package/{test-compat.mjs → test/test-compat.mjs} +3 -3
  44. package/{test-concurrency.mjs → test/test-concurrency.mjs} +3 -3
  45. package/{test-conflict.mjs → test/test-conflict.mjs} +2 -2
  46. package/{test-context-history.mjs → test/test-context-history.mjs} +1 -1
  47. package/{test-control-schema.mjs → test/test-control-schema.mjs} +1 -1
  48. package/test/test-detect.mjs +125 -0
  49. package/test/test-dream-plan.mjs +72 -0
  50. package/test/test-dsh-registration.mjs +111 -0
  51. package/{test-episodes.mjs → test/test-episodes.mjs} +5 -5
  52. package/{test-explicit-memory.mjs → test/test-explicit-memory.mjs} +4 -4
  53. package/{test-field-lineage.mjs → test/test-field-lineage.mjs} +4 -4
  54. package/{test-history.mjs → test/test-history.mjs} +4 -4
  55. package/{test-inline-summary.mjs → test/test-inline-summary.mjs} +5 -5
  56. package/{test-legacy-source-identity.mjs → test/test-legacy-source-identity.mjs} +2 -2
  57. package/{test-links.mjs → test/test-links.mjs} +4 -4
  58. package/{test-managed-access.mjs → test/test-managed-access.mjs} +29 -5
  59. package/{test-managed-activation.mjs → test/test-managed-activation.mjs} +5 -5
  60. package/{test-meaning.mjs → test/test-meaning.mjs} +6 -6
  61. package/{test-native-service-lifecycle.mjs → test/test-native-service-lifecycle.mjs} +1 -1
  62. package/{test-native-write-validation.mjs → test/test-native-write-validation.mjs} +4 -4
  63. package/{test-packaging.mjs → test/test-packaging.mjs} +32 -5
  64. package/{test-paired-analysis.mjs → test/test-paired-analysis.mjs} +1 -1
  65. package/{test-patch.mjs → test/test-patch.mjs} +11 -11
  66. package/{test-plugin-contract.mjs → test/test-plugin-contract.mjs} +4 -4
  67. package/{test-privacy.mjs → test/test-privacy.mjs} +3 -3
  68. package/{test-project-stores.mjs → test/test-project-stores.mjs} +5 -5
  69. package/{test-publication.mjs → test/test-publication.mjs} +4 -4
  70. package/{test-restore.mjs → test/test-restore.mjs} +11 -11
  71. package/{test-retrieval.mjs → test/test-retrieval.mjs} +2 -2
  72. package/{test-revision-admission.mjs → test/test-revision-admission.mjs} +3 -3
  73. package/{test-rule-admission.mjs → test/test-rule-admission.mjs} +8 -8
  74. package/{test-runtime-startup.mjs → test/test-runtime-startup.mjs} +2 -2
  75. package/{test-schema.mjs → test/test-schema.mjs} +2 -2
  76. package/{test-security.mjs → test/test-security.mjs} +6 -6
  77. package/test/test-semantic-suite.mjs +20 -0
  78. package/{test-service-readiness.mjs → test/test-service-readiness.mjs} +1 -1
  79. package/{test-smell.mjs → test/test-smell.mjs} +4 -4
  80. package/{test-snapshots.mjs → test/test-snapshots.mjs} +3 -3
  81. package/{test-source-completeness.mjs → test/test-source-completeness.mjs} +6 -6
  82. package/{test-source-key.mjs → test/test-source-key.mjs} +2 -2
  83. package/{test-source-retirement.mjs → test/test-source-retirement.mjs} +4 -4
  84. package/{test-status-both.mjs → test/test-status-both.mjs} +12 -4
  85. package/{test-supersede.mjs → test/test-supersede.mjs} +3 -3
  86. package/test/test-telemetry.mjs +60 -0
  87. package/{test-text-admission.mjs → test/test-text-admission.mjs} +6 -6
  88. package/{test-tool-inputs.mjs → test/test-tool-inputs.mjs} +1 -1
  89. package/{test-tool-observations.mjs → test/test-tool-observations.mjs} +6 -6
  90. package/{test-tool-write-guard.mjs → test/test-tool-write-guard.mjs} +3 -3
  91. package/{test-tool-write-lifecycle.mjs → test/test-tool-write-lifecycle.mjs} +3 -3
  92. package/{test-usability.mjs → test/test-usability.mjs} +7 -7
  93. package/{test-validation-source-refs.mjs → test/test-validation-source-refs.mjs} +2 -2
  94. package/{test-validation-transition-context.mjs → test/test-validation-transition-context.mjs} +1 -1
  95. package/{test-withdrawal-archives.mjs → test/test-withdrawal-archives.mjs} +6 -6
  96. package/{test-withdrawal.mjs → test/test-withdrawal.mjs} +2 -2
  97. package/{test.mjs → test/test.mjs} +4 -4
  98. package/version.mjs +1 -1
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 可靠性测试(soak):走**官方离线安装路径**,反复启动真实 DSH 宿主,并做一次 SIGKILL 恢复。
4
+ *
5
+ * node scripts/soak.mjs # 3 次正常启停 + 1 次崩溃恢复
6
+ * node scripts/soak.mjs --cycles 5
7
+ * node scripts/soak.mjs --json
8
+ *
9
+ * 断言:
10
+ * · 官方 CLI 能从 tgz 离线安装,profile 自动选中 bundle;
11
+ * · 每次宿主启动都写入 .loaded.json:版本一致、6 工具、degraded 为空、sourceControl=managed;
12
+ * · 每次优雅退出后,记忆内容文件逐字节不变(无模型调用时不该改动);
13
+ * · SIGKILL 后重启可加载,且没有遗留 .write.lock / .publication.json;
14
+ * · 存储目录权限为 0700、文件 0600。
15
+ */
16
+
17
+ import { spawn, spawnSync } from 'node:child_process'
18
+ import { createHash } from 'node:crypto'
19
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs'
20
+ import { homedir } from 'node:os'
21
+ import { dirname, join, resolve } from 'node:path'
22
+ import { fileURLToPath } from 'node:url'
23
+
24
+ import { VERSION } from '../version.mjs'
25
+
26
+ const HERE = dirname(fileURLToPath(import.meta.url))
27
+ const ROOT = join(HERE, '..')
28
+ const CLI = process.env.DSH_CLI ?? '/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh'
29
+ const TOOLS = ['memory_write', 'memory_forget', 'memory_links', 'memory_search', 'memory_prune', 'memory_status']
30
+ const DATA_FILES = /^(MEMORY\.md|(user|feedback|project|reference)\.md|\.candidates\.json|\.episodes\.json|\.history\.jsonl)$/
31
+ const hash = (file) => createHash('sha256').update(readFileSync(file)).digest('hex')
32
+ const sleep = (ms) => new Promise((done) => setTimeout(done, ms))
33
+
34
+ function dataFingerprint(memoryRoot) {
35
+ const out = []
36
+ const walk = (dir) => {
37
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
38
+ if (entry.name.startsWith('.')) continue
39
+ const full = join(dir, entry.name)
40
+ if (entry.isDirectory()) walk(full)
41
+ else if (entry.isFile() && DATA_FILES.test(entry.name)) out.push([full.slice(memoryRoot.length), statSync(full).size, hash(full)])
42
+ }
43
+ }
44
+ if (existsSync(memoryRoot)) walk(memoryRoot)
45
+ return JSON.stringify(out.sort())
46
+ }
47
+
48
+ function packTarball() {
49
+ const result = spawnSync('npm', ['pack', '--silent'], { cwd: ROOT, encoding: 'utf8', timeout: 60000 })
50
+ if (result.status !== 0) throw new Error(`npm pack 失败:${result.stderr ?? ''}`)
51
+ const name = result.stdout.trim().split('\n').at(-1)
52
+ return join(ROOT, name)
53
+ }
54
+
55
+ function install(tgz, home) {
56
+ const profile = join(home, 'profiles', 'soak')
57
+ mkdirSync(profile, { recursive: true })
58
+ writeFileSync(join(profile, 'package.json'), JSON.stringify({ name: 'solopup-soak', private: true, dependencies: {}, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] } } }, null, 2))
59
+ writeFileSync(join(profile, 'cordis.yml'), '[]\n')
60
+ const result = spawnSync(CLI, ['plugin', '--profile', 'soak', 'add', tgz, '--offline', '--ignore-scripts'], {
61
+ env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, DSH_HOME: home },
62
+ encoding: 'utf8',
63
+ timeout: 180000,
64
+ })
65
+ if (result.status !== 0) throw new Error(`官方离线安装失败:${(result.stderr ?? result.stdout ?? '').slice(-500)}`)
66
+ const pkg = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8'))
67
+ if (!pkg.dsh?.profile?.bundles?.includes('solopup-memory')) throw new Error('安装后 bundle 未自动选中')
68
+ return profile
69
+ }
70
+
71
+ async function startHost(home, profile) {
72
+ const child = spawn(CLI, ['--profile', 'soak', '--port', '0', '--no-open'], {
73
+ env: { PATH: process.env.PATH, HOME: home, USERPROFILE: home, DSH_HOME: home, TMPDIR: join(home, 'tmp') },
74
+ stdio: ['ignore', 'pipe', 'pipe'],
75
+ })
76
+ let output = ''
77
+ child.stdout.on('data', (chunk) => (output += chunk))
78
+ child.stderr.on('data', (chunk) => (output += chunk))
79
+ const marker = join(home, 'memory', '.loaded.json')
80
+ const started = Date.now()
81
+ const deadline = started + 40000
82
+ while (Date.now() < deadline) {
83
+ await sleep(200)
84
+ if (!existsSync(marker)) continue
85
+ try {
86
+ const stamp = JSON.parse(readFileSync(marker, 'utf8'))
87
+ if (stamp?.pid === child.pid && stamp.version === VERSION) {
88
+ return { child, stamp, loadMs: Date.now() - started, output: () => output }
89
+ }
90
+ } catch {
91
+ /* 写入中,重试 */
92
+ }
93
+ }
94
+ child.kill('SIGKILL')
95
+ throw new Error(`宿主未在 40s 内就绪:${output.slice(-400)}`)
96
+ }
97
+
98
+ async function stopHost(child) {
99
+ const exited = new Promise((done) => child.once('exit', (code, signal) => done({ code, signal })))
100
+ child.kill('SIGTERM')
101
+ const timer = setTimeout(() => child.kill('SIGKILL'), 5000)
102
+ const result = await exited
103
+ clearTimeout(timer)
104
+ return result
105
+ }
106
+
107
+ function assertStorePermissions(memoryRoot) {
108
+ const mode = (path, expected) => {
109
+ const value = statSync(path).mode & 0o777
110
+ if (value !== expected) throw new Error(`${path} 权限 ${value.toString(8)},期望 ${expected.toString(8)}`)
111
+ }
112
+ mode(memoryRoot, 0o700)
113
+ for (const name of readdirSync(memoryRoot)) {
114
+ if (name === 'projects') continue
115
+ const full = join(memoryRoot, name)
116
+ if (statSync(full).isFile() && !name.endsWith('.lock') && !name.endsWith('.json')) mode(full, 0o600)
117
+ }
118
+ }
119
+
120
+ export async function soak({ cycles = 3 } = {}) {
121
+ const base = realpathSync(mkdtempSync(join(homedir(), '.dsh-soak-')))
122
+ const home = join(base, 'home')
123
+ mkdirSync(home)
124
+ mkdirSync(join(home, 'tmp'))
125
+ const memoryRoot = join(home, 'memory')
126
+ const report = { cycles, version: VERSION, startedAt: new Date().toISOString(), loads: [], crash: null, dataStable: null, permissions: null }
127
+ try {
128
+ const tgz = packTarball()
129
+ const profile = install(tgz, home)
130
+ const baseline = dataFingerprint(memoryRoot)
131
+ for (let index = 0; index < cycles; index += 1) {
132
+ const run = await startHost(home, profile)
133
+ if (run.stamp.degraded?.length) throw new Error(`第 ${index + 1} 次加载 degraded:${run.stamp.degraded.join(',')}`)
134
+ if (run.stamp.sourceControl?.state !== 'managed') throw new Error(`第 ${index + 1} 次 sourceControl=${run.stamp.sourceControl?.state}`)
135
+ const missing = TOOLS.filter((name) => !(run.stamp.tools ?? []).includes(name))
136
+ if (missing.length) throw new Error(`第 ${index + 1} 次缺工具:${missing.join(',')}`)
137
+ if (index === 0) {
138
+ // 用官方 accept.mjs 对“正在运行的宿主”做只读安装/运行验收。
139
+ const accept = spawnSync(process.execPath, [join(ROOT, 'accept.mjs'), '--profile', 'soak'], {
140
+ env: { ...process.env, HOME: home, USERPROFILE: home, DSH_HOME: home },
141
+ encoding: 'utf8',
142
+ timeout: 30000,
143
+ })
144
+ let parsed
145
+ try {
146
+ parsed = JSON.parse(accept.stdout)
147
+ } catch {
148
+ throw new Error(`accept.mjs 输出无法解析:${(accept.stderr ?? accept.stdout ?? '').slice(-400)}`)
149
+ }
150
+ if (!parsed.passed) throw new Error(`accept.mjs 未通过:${JSON.stringify(parsed.checks.filter((row) => !row.passed))}`)
151
+ report.accept = { passed: true, checks: parsed.checks.length }
152
+ }
153
+ const stopped = await stopHost(run.child)
154
+ if (stopped.code !== 0 && stopped.signal !== 'SIGTERM') throw new Error(`第 ${index + 1} 次退出异常:${JSON.stringify(stopped)}`)
155
+ report.loads.push({ cycle: index + 1, loadMs: run.loadMs, exit: stopped.code ?? stopped.signal })
156
+ }
157
+ report.permissions = (assertStorePermissions(memoryRoot), 'ok')
158
+
159
+ // 崩溃恢复:加载后立刻 SIGKILL,再重启。
160
+ const crashRun = await startHost(home, profile)
161
+ crashRun.child.kill('SIGKILL')
162
+ await new Promise((done) => crashRun.child.once('exit', done))
163
+ const leftovers = ['.write.lock', '.publication.json'].filter((name) => existsSync(join(memoryRoot, name)))
164
+ if (leftovers.length) throw new Error(`崩溃后遗留:${leftovers.join(',')}`)
165
+ const restarted = await startHost(home, profile)
166
+ if (restarted.stamp.degraded?.length) throw new Error(`崩溃恢复后 degraded:${restarted.stamp.degraded.join(',')}`)
167
+ const restartMs = restarted.loadMs
168
+ await stopHost(restarted.child)
169
+ report.crash = { recovered: true, restartMs, leftovers: 0 }
170
+
171
+ // 并发:同一 store 上两个宿主同时运行,随后第三个宿主重新加载。
172
+ const hostA = await startHost(home, profile)
173
+ const hostB = await startHost(home, profile)
174
+ await sleep(1200)
175
+ const aliveA = hostA.child.exitCode === null
176
+ const aliveB = hostB.child.exitCode === null
177
+ if (!aliveA || !aliveB) throw new Error(`并发宿主未同时存活:A=${aliveA} B=${aliveB}`)
178
+ await stopHost(hostA.child)
179
+ await stopHost(hostB.child)
180
+ const reload = await startHost(home, profile)
181
+ if (reload.stamp.degraded?.length) throw new Error(`并发后 reload degraded:${reload.stamp.degraded.join(',')}`)
182
+ await stopHost(reload.child)
183
+ report.concurrency = { hosts: 2, bothAlive: true, reloadAfter: true }
184
+
185
+ report.dataStable = dataFingerprint(memoryRoot) === baseline
186
+ if (!report.dataStable) throw new Error('多轮启停后记忆内容文件发生变化(无模型调用时应保持不变)')
187
+ report.passed = true
188
+ } catch (error) {
189
+ report.passed = false
190
+ report.error = error.message
191
+ } finally {
192
+ rmSync(base, { recursive: true, force: true })
193
+ }
194
+ report.finishedAt = new Date().toISOString()
195
+ return report
196
+ }
197
+
198
+ if (process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`) {
199
+ const argv = process.argv.slice(2)
200
+ const cyclesAt = argv.indexOf('--cycles')
201
+ const cycles = cyclesAt >= 0 ? Number(argv[cyclesAt + 1]) : 3
202
+ const report = await soak({ cycles })
203
+ if (argv.includes('--json')) process.stdout.write(JSON.stringify(report, null, 2) + '\n')
204
+ else {
205
+ const lines = [
206
+ `SoloPup 可靠性测试(官方离线安装 + 真实宿主)· v${report.version}`,
207
+ `正常启停 ${report.loads.length} 次:` + report.loads.map((row) => `${row.loadMs}ms`).join('、') + `;退出 ${report.loads.map((row) => row.exit).join('、')}`,
208
+ report.crash ? `崩溃恢复:SIGKILL 后重启 ${report.crash.restartMs}ms,遗留锁/发布标记 ${report.crash.leftovers} 个` : '崩溃恢复:未执行',
209
+ report.concurrency ? `并发宿主:${report.concurrency.hosts} 个同时存活=${report.concurrency.bothAlive},之后 reload=${report.concurrency.reloadAfter}` : '并发宿主:未执行',
210
+ `记忆内容文件多轮不变:${report.dataStable === true ? '是' : '否'}`,
211
+ `存储权限:${report.permissions ?? '未检查'}`,
212
+ report.accept ? `官方 accept 验收:${report.accept.checks} 项通过` : '官方 accept 验收:未执行',
213
+ ]
214
+ if (!report.passed) lines.push(`[FAIL] ${report.error}`)
215
+ else lines.push('[PASS] 可靠性测试通过')
216
+ process.stdout.write(lines.join('\n') + '\n')
217
+ }
218
+ if (!report.passed) process.exit(1)
219
+ }
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * L3 长期观测:把账本原料汇成“管道是否在产出”的指标。只读,不调用模型。
4
+ *
5
+ * node scripts/telemetry.mjs # 人类可读
6
+ * node scripts/telemetry.mjs --json # 机器可读
7
+ * node scripts/telemetry.mjs --check # 有 error 级告警时退出码 1
8
+ *
9
+ * 这些指标是“做梦”调度的输入:没有变更、没有产出、待审积压时,不该启动整理。
10
+ */
11
+
12
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
13
+ import { homedir } from 'node:os'
14
+ import { dirname, join, resolve } from 'node:path'
15
+ import { fileURLToPath } from 'node:url'
16
+
17
+ const HERE = dirname(fileURLToPath(import.meta.url))
18
+ const { candidateSnapshot } = await import(join(HERE, '..', 'candidates.mjs'))
19
+
20
+ const DAY = 86_400_000
21
+
22
+ function readText(file) {
23
+ try {
24
+ return readFileSync(file, 'utf8')
25
+ } catch {
26
+ return undefined
27
+ }
28
+ }
29
+
30
+ function readJson(file) {
31
+ try {
32
+ return JSON.parse(readFileSync(file, 'utf8'))
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ function indexEntryCount(dir) {
39
+ const text = readText(join(dir, 'MEMORY.md')) ?? ''
40
+ return text.split(/\r?\n/).filter((line) => line.trim().startsWith('- [')).length
41
+ }
42
+
43
+ function snapshot(dir, scope) {
44
+ try {
45
+ return candidateSnapshot(dir, scope)
46
+ } catch {
47
+ return undefined
48
+ }
49
+ }
50
+
51
+ function pendingStats(snapshot, now) {
52
+ const pending = (snapshot?.records ?? []).filter((record) => record.status === 'pending')
53
+ let oldest = null
54
+ for (const record of pending) {
55
+ const stamp = Date.parse(record.createdAt ?? '')
56
+ if (Number.isFinite(stamp) && (oldest === null || stamp < oldest)) oldest = stamp
57
+ }
58
+ return { count: pending.length, oldestDays: oldest === null ? null : Math.floor((now - oldest) / DAY) }
59
+ }
60
+
61
+ export function collect(root, now = Date.now()) {
62
+ const stores = []
63
+ const globalSnapshot = snapshot(root, 'global')
64
+ stores.push({ scope: 'global', dir: root, indexLines: indexEntryCount(root), ...pendingStats(globalSnapshot, now) })
65
+ let projectNames = []
66
+ try {
67
+ projectNames = readdirSync(join(root, 'projects'), { withFileTypes: true })
68
+ .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
69
+ .map((entry) => entry.name)
70
+ .sort()
71
+ } catch {
72
+ projectNames = []
73
+ }
74
+ for (const name of projectNames) {
75
+ const dir = join(root, 'projects', name)
76
+ const snap = snapshot(dir, 'project')
77
+ stores.push({ scope: 'project', name, dir, indexLines: indexEntryCount(dir), ...pendingStats(snap, now) })
78
+ }
79
+
80
+ // 审计账本:只看 capture 与 write,不推断“没记 = 没发生”。
81
+ const auditText = readText(join(root, '.audit.jsonl')) ?? ''
82
+ const events = []
83
+ for (const line of auditText.split(/\r?\n/)) {
84
+ if (line.trim().length === 0) continue
85
+ try {
86
+ events.push(JSON.parse(line))
87
+ } catch {
88
+ /* 坏行不算 */
89
+ }
90
+ }
91
+ const captures = events.filter((event) => event.op === 'capture')
92
+ const skipping = captures.filter((event) => event.reason === 'short-session-below-threshold')
93
+ const byOutcome = (outcome) => captures.filter((event) => event.outcome === outcome).length
94
+ const lastCaptureAt = captures.map((event) => Date.parse(event.at ?? '')).filter(Number.isFinite).sort().at(-1) ?? null
95
+ const inserts = captures.filter((event) => Number(event.inserted) > 0 && Number.isFinite(Date.parse(event.at ?? '')))
96
+ const lastInsertAt = inserts.map((event) => Date.parse(event.at)).sort().at(-1) ?? null
97
+
98
+ const jobState = readJson(join(root, '.capture-jobs.json'))
99
+ const jobs = Array.isArray(jobState?.jobs) ? jobState.jobs : []
100
+ const jobByStatus = {}
101
+ for (const job of jobs) jobByStatus[job.status] = (jobByStatus[job.status] ?? 0) + 1
102
+ const lastJobAt = jobs.map((job) => Number(job.updatedAt ?? job.at)).filter(Number.isFinite).sort().at(-1) ?? null
103
+
104
+ const state = readJson(join(root, '.capture-state.json'))
105
+ const candidates = (globalSnapshot?.records ?? [])
106
+ const lastRecordAt = candidates.map((record) => Date.parse(record.createdAt ?? '')).filter(Number.isFinite).sort().at(-1) ?? null
107
+ const lastOutputAt = Math.max(lastInsertAt ?? 0, lastRecordAt ?? 0) || null
108
+ const zeroOutputDays = lastOutputAt === null ? null : Math.floor((now - lastOutputAt) / DAY)
109
+
110
+ const warnings = []
111
+ if (captures.length >= 5) {
112
+ const success = byOutcome('ok') / captures.length
113
+ if (success < 0.5) warnings.push({ id: 'T1-low-capture-success', severity: 'warning', message: `捕获成功率 ${(success * 100).toFixed(1)}%(${byOutcome('ok')}/${captures.length})` })
114
+ }
115
+ if (zeroOutputDays !== null && zeroOutputDays >= 3) {
116
+ warnings.push({ id: 'T2-zero-output', severity: zeroOutputDays >= 7 ? 'error' : 'warning', message: `已 ${zeroOutputDays} 天没有新记忆入库` })
117
+ }
118
+ if (skipping.length >= 3) {
119
+ warnings.push({ id: 'T3-short-sessions', severity: 'warning', message: `${skipping.length} 次捕获因会话过短被跳过(每 ${state?.everyTurns ?? 4} 轮才触发)` })
120
+ }
121
+ for (const store of stores) {
122
+ if (store.scope === 'project' && store.oldestDays !== null && store.oldestDays >= 7) {
123
+ warnings.push({ id: 'T4-pending-stale', severity: 'warning', message: `待审候选已挂 ${store.oldestDays} 天(${store.name})` })
124
+ }
125
+ }
126
+ if (stores.every((store) => store.indexLines === 0)) {
127
+ warnings.push({ id: 'T5-no-memory', severity: 'info', message: '所有库都没有索引条目' })
128
+ }
129
+
130
+ return {
131
+ root,
132
+ generatedAt: new Date(now).toISOString(),
133
+ stores,
134
+ capture: {
135
+ total: captures.length,
136
+ ok: byOutcome('ok'),
137
+ error: byOutcome('error'),
138
+ rejected: byOutcome('rejected'),
139
+ partial: byOutcome('partial'),
140
+ skipped: skipping.length,
141
+ successRate: captures.length === 0 ? null : byOutcome('ok') / captures.length,
142
+ lastAt: lastCaptureAt === null ? null : new Date(lastCaptureAt).toISOString(),
143
+ },
144
+ jobs: { total: jobs.length, byStatus: jobByStatus, lastAt: lastJobAt === null ? null : new Date(lastJobAt).toISOString() },
145
+ output: {
146
+ lastInsertAt: lastInsertAt === null ? null : new Date(lastInsertAt).toISOString(),
147
+ lastRecordAt: lastRecordAt === null ? null : new Date(lastRecordAt).toISOString(),
148
+ zeroOutputDays,
149
+ captureState: state === undefined ? null : { at: state.at ?? null, turns: state.turns ?? null, everyTurns: state.everyTurns ?? null, running: state.running === true },
150
+ },
151
+ warnings,
152
+ }
153
+ }
154
+
155
+ const argv = process.argv.slice(2)
156
+ const flag = (name) => argv.includes(name)
157
+ const option = (name) => {
158
+ const at = argv.indexOf(name)
159
+ return at >= 0 ? argv[at + 1] : undefined
160
+ }
161
+
162
+ if (import.meta.url === `file://${process.argv[1]}`) {
163
+ const root = resolve(option('--root') ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'memory'))
164
+ if (!existsSync(root)) {
165
+ console.error(`[telemetry] 记忆根不存在:${root}`)
166
+ process.exit(2)
167
+ }
168
+ const report = collect(root)
169
+ if (flag('--json')) process.stdout.write(JSON.stringify(report, null, 2) + '\n')
170
+ else {
171
+ const pct = (value) => (value === null ? '—' : `${(value * 100).toFixed(1)}%`)
172
+ const lines = [
173
+ `SoloPup 长期观测 · ${report.generatedAt}`,
174
+ `根目录:${report.root}`,
175
+ `捕获:${report.capture.total} 次(ok ${report.capture.ok}、error ${report.capture.error}、rejected ${report.capture.rejected}、partial ${report.capture.partial}、跳过短会话 ${report.capture.skipped}),成功率 ${pct(report.capture.successRate)}`,
176
+ `作业:${report.jobs.total} 个(${Object.entries(report.jobs.byStatus).map(([status, count]) => `${status} ${count}`).join('、') || '无'})`,
177
+ `最后一次入库:${report.output.lastInsertAt ?? '从未'};零产出 ${report.output.zeroOutputDays ?? '—'} 天`,
178
+ ]
179
+ for (const store of report.stores) {
180
+ const label = store.scope === 'global' ? 'global' : `project ${store.name}`
181
+ lines.push(`库 ${label}:索引 ${store.indexLines} 条;待审 ${store.count}(最老 ${store.oldestDays ?? '—'} 天)`)
182
+ }
183
+ for (const warning of report.warnings) lines.push(` [${warning.severity}] ${warning.id} ${warning.message}`)
184
+ process.stdout.write(lines.join('\n') + '\n')
185
+ }
186
+ if (flag('--check') && report.warnings.some((warning) => warning.severity === 'error')) process.exit(1)
187
+ }
@@ -22,7 +22,7 @@ const ROOT = join(HERE, '..')
22
22
  const withAccept = !process.argv.includes('--no-accept')
23
23
 
24
24
  const testArgs=JSON.parse(readFileSync(join(ROOT,'package.json'),'utf8')).scripts.test.trim().split(/\s+/)
25
- if(testArgs[0]!=='node'||testArgs[1]!=='--test'||testArgs.slice(2).some(arg=>!/^test(?:-[a-z0-9-]+)?\.mjs$/.test(arg)&&!/^--test-concurrency=[1-9]\d*$/.test(arg)))throw Error('unsupported-test-command')
25
+ if(testArgs[0]!=='node'||testArgs[1]!=='--test'||testArgs.slice(2).some(arg=>!/^test\/test(?:-[a-z0-9-]+)?\.mjs$/.test(arg)&&!/^--test-concurrency=[1-9]\d*$/.test(arg)))throw Error('unsupported-test-command')
26
26
  const SUITES=testArgs.slice(2).filter(arg=>arg.endsWith('.mjs'))
27
27
 
28
28
  function run(label, command, args) {
@@ -47,8 +47,10 @@ if (missing.length > 0) {
47
47
  process.exit(1)
48
48
  }
49
49
 
50
+ results.push(run('安全自查', process.execPath, ['scripts/security-check.mjs']))
50
51
  results.push(run('测试', process.execPath, testArgs.slice(1)))
51
52
  results.push(run('加载探针', process.execPath, ['scripts/probe-load.mjs']))
53
+ results.push(run('宿主历史契约', process.execPath, ['scripts/probe-history.mjs']))
52
54
  results.push(run('评测门禁', process.execPath, ['eval/run-eval.mjs', '--check']))
53
55
  results.push(run('可用性门禁', process.execPath, ['eval/mechanical-table.mjs', '--check']))
54
56
  if (withAccept) results.push(run('验收', process.execPath, ['accept.mjs']))
@@ -1,7 +1,7 @@
1
- import {prepareMountedFixture} from './fixture-host.mjs'
1
+ import {prepareMountedFixture} from '../fixture-host.mjs'
2
2
  import {realpathSync} from 'node:fs'
3
3
  /**
4
- * 对抗测试。跑法:node --test ~/.dsh/plugins/memory/test-adversarial.mjs
4
+ * 对抗测试。跑法:node --test ~/.dsh/plugins/memory/test/test-adversarial.mjs
5
5
  *
6
6
  * 三层:
7
7
  * 1. 题库驱动 —— corpus/injections.json 里每条 payload 按 expect 断言
@@ -25,10 +25,10 @@ import { join, dirname } from 'node:path'
25
25
  import { fileURLToPath } from 'node:url'
26
26
  import { tmpdir } from 'node:os'
27
27
 
28
- import { findSecrets, sanitizeForInjection, withLock } from './security.mjs'
29
- import { addIndexLine, apply, indexLine, isExpired, parseExpiry, storeDirFor, storeSize, writeMemory } from './index.mjs'
28
+ import { findSecrets, sanitizeForInjection, withLock } from '../security.mjs'
29
+ import { addIndexLine, apply, indexLine, isExpired, parseExpiry, storeDirFor, storeSize, writeMemory } from '../index.mjs'
30
30
 
31
- const HERE = dirname(fileURLToPath(import.meta.url))
31
+ const HERE = dirname(dirname(fileURLToPath(import.meta.url)))
32
32
 
33
33
  const INVISIBLE = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F\u200B-\u200F\u202A-\u202E\u2060-\u2069\uFEFF]|[\u{E0000}-\u{E007F}]/u
34
34
 
@@ -1,16 +1,16 @@
1
- import {applyWithValidationFixture as apply} from './fixture-host.mjs'
1
+ import {applyWithValidationFixture as apply} from '../fixture-host.mjs'
2
2
  import {test} from 'node:test'
3
3
  import assert from 'node:assert/strict'
4
4
  import {mkdtempSync,realpathSync,mkdirSync,readFileSync,writeFileSync,rmSync,existsSync} from 'node:fs'
5
5
  import {join} from 'node:path'
6
6
  import {tmpdir} from 'node:os'
7
7
  import {createHash,randomUUID} from 'node:crypto'
8
- import {indexLine, searchStore, withdrawManagedSource, adoptCandidate, storeRevision, recoverMemoryPublication} from './index.mjs'
9
- import {writeFixtureMemory} from './fixture-host.mjs'
10
- import {listCaptureJobs} from './capture-jobs.mjs'
11
- import {candidateSnapshot,stageCandidate} from './candidates.mjs'
12
- import {normalizeMeaning} from './meaning.mjs'
13
- import {buildUserPrompt} from './capture-llm.mjs'
8
+ import {indexLine, searchStore, withdrawManagedSource, adoptCandidate, storeRevision, recoverMemoryPublication} from '../index.mjs'
9
+ import {writeFixtureMemory} from '../fixture-host.mjs'
10
+ import {listCaptureJobs} from '../capture-jobs.mjs'
11
+ import {candidateSnapshot,stageCandidate} from '../candidates.mjs'
12
+ import {normalizeMeaning} from '../meaning.mjs'
13
+ import {buildUserPrompt} from '../capture-llm.mjs'
14
14
  const hash=text=>createHash('sha256').update(text).digest('hex')
15
15
  async function box(run,options={}){
16
16
  const base=realpathSync(mkdtempSync(join(tmpdir(),'dsh-belief-'))),home=join(base,'home'),cwd=join(base,'project'),previous=process.env.DSH_HOME
@@ -5,11 +5,11 @@ import { join } from 'node:path'
5
5
  import { tmpdir } from 'node:os'
6
6
  import { spawn, spawnSync } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
- import { stageCandidate, listCandidates, rejectCandidate, withdrawSource, pruneCandidates } from './candidates.mjs'
9
- import { writeSafely } from './security.mjs'
8
+ import { stageCandidate, listCandidates, rejectCandidate, withdrawSource, pruneCandidates } from '../candidates.mjs'
9
+ import { writeSafely } from '../security.mjs'
10
10
 
11
- const modulePath = fileURLToPath(new URL('./candidates.mjs', import.meta.url))
12
- const cli = fileURLToPath(new URL('./scripts/candidates.mjs', import.meta.url))
11
+ const modulePath = fileURLToPath(new URL('../candidates.mjs', import.meta.url))
12
+ const cli = fileURLToPath(new URL('../scripts/candidates.mjs', import.meta.url))
13
13
  const fixture = (n = 0) => ({ type: 'user', scope: 'project', summary: `本项目可能偏好低反差方案 ${n}`,
14
14
  sessionId: 'session-test', indexRevision: 'a'.repeat(64),
15
15
  evidence: [{ messageId: `message-${n}`, hash: 'b'.repeat(64), quote: '这次我选低反差的那一张图' }] })
@@ -1,14 +1,14 @@
1
- import {applyWithValidationFixture as apply} from './fixture-host.mjs'
1
+ import {applyWithValidationFixture as apply} from '../fixture-host.mjs'
2
2
  import {test} from 'node:test'
3
3
  import assert from 'node:assert/strict'
4
4
  import {mkdtempSync,realpathSync,mkdirSync,readFileSync,writeFileSync,existsSync,rmSync} from 'node:fs'
5
5
  import {join} from 'node:path'
6
6
  import {tmpdir} from 'node:os'
7
- import {parseCaptureBatch,buildCaptureDiagnostic,normalizeCaptureDiagnostic,captureDiagnosticOutcome,CAPTURE_RESPONSE_BYTES} from './capture-diagnostics.mjs'
8
- import {extractWithLlm} from './capture-llm.mjs'
9
- import {searchStore, indexLine} from './index.mjs'
10
- import {listCaptureJobs,captureJobStatus,claimCaptureJob,finishCaptureJob} from './capture-jobs.mjs'
11
- import {writeFixtureMemory} from './fixture-host.mjs'
7
+ import {parseCaptureBatch,buildCaptureDiagnostic,normalizeCaptureDiagnostic,captureDiagnosticOutcome,CAPTURE_RESPONSE_BYTES} from '../capture-diagnostics.mjs'
8
+ import {extractWithLlm} from '../capture-llm.mjs'
9
+ import {searchStore, indexLine} from '../index.mjs'
10
+ import {listCaptureJobs,captureJobStatus,claimCaptureJob,finishCaptureJob} from '../capture-jobs.mjs'
11
+ import {writeFixtureMemory} from '../fixture-host.mjs'
12
12
  const item=(n=1)=>({type:'user',summary:'持久有效的测试条目'+n})
13
13
  test('an explicit empty array is a model-reported no-op, never proof that no durable information exists',()=>{
14
14
  const batch=parseCaptureBatch('[]',3),d=buildCaptureDiagnostic(batch)
@@ -5,10 +5,10 @@ import assert from 'node:assert/strict'
5
5
  import {mkdtempSync,realpathSync,mkdirSync,readFileSync,rmSync} from 'node:fs'
6
6
  import {join} from 'node:path'
7
7
  import {tmpdir} from 'node:os'
8
- import {apply,searchStore,withdrawManagedSource} from './index.mjs'
9
- import {candidateSnapshot} from './candidates.mjs'
10
- import {VALIDATION_FIELDS} from './capture-validation-contract.mjs'
11
- import {writeFixtureMemory} from './fixture-host.mjs'
8
+ import {apply,searchStore,withdrawManagedSource} from '../index.mjs'
9
+ import {candidateSnapshot} from '../candidates.mjs'
10
+ import {VALIDATION_FIELDS} from '../capture-validation-contract.mjs'
11
+ import {writeFixtureMemory} from '../fixture-host.mjs'
12
12
  const modes=['value','expiring-value','complete','changed-condition','inferred','wrong-target','unsupported','uncertain','withdraw-during','root-overflow','self-excluding'];
13
13
  for(const mode of modes)test('automatic code-owned field lineage: '+mode,async()=>{
14
14
  const base=realpathSync(mkdtempSync(join(tmpdir(),'auto-field-lineage-'))),home=join(base,'home'),cwd=join(base,'project'),root=join(home,'memory'),previous=process.env.DSH_HOME;
@@ -1,5 +1,5 @@
1
- import {applyWithValidationFixture as apply} from './fixture-host.mjs'
2
- import {prepareMountedFixture} from './fixture-host.mjs'
1
+ import {applyWithValidationFixture as apply} from '../fixture-host.mjs'
2
+ import {prepareMountedFixture} from '../fixture-host.mjs'
3
3
  import {realpathSync} from 'node:fs'
4
4
  import {test} from 'node:test'
5
5
  import assert from 'node:assert/strict'
@@ -8,10 +8,10 @@ import {join} from 'node:path'
8
8
  import {tmpdir} from 'node:os'
9
9
  import {fileURLToPath} from 'node:url'
10
10
  import {spawnSync} from 'node:child_process'
11
- import {captureMessage} from './capture-evidence.mjs'
12
- import {captureSessionEligible,captureInputDescriptor,enqueueCaptureInput,listCaptureInputs,pendingCaptureInputs,bindCaptureInput,blockCaptureInput,reconstructCaptureInput,pruneCaptureInputs,observeCaptureSource} from './capture-inputs.mjs'
13
- import {claimCaptureJob,listCaptureJobs,finishCaptureJob} from './capture-jobs.mjs'
14
- import {storeDirFor, withdrawManagedSource} from './index.mjs'
11
+ import {captureMessage} from '../capture-evidence.mjs'
12
+ import {captureSessionEligible,captureInputDescriptor,enqueueCaptureInput,listCaptureInputs,pendingCaptureInputs,bindCaptureInput,blockCaptureInput,reconstructCaptureInput,pruneCaptureInputs,observeCaptureSource} from '../capture-inputs.mjs'
13
+ import {claimCaptureJob,listCaptureJobs,finishCaptureJob} from '../capture-jobs.mjs'
14
+ import {storeDirFor, withdrawManagedSource} from '../index.mjs'
15
15
 
16
16
  const options={everyTurns:1,maxEntries:3,maxTokens:800,maxInputBytes:24*1024,timeoutMs:25000}
17
17
  const route={provider:'fixture',model:'fixture-model'}
@@ -83,7 +83,7 @@ test('损坏、多余正文字段、链接及无效引用拒绝并保留现场',
83
83
  }))
84
84
 
85
85
  test('来源入队后真实进程SIGKILL,重开保留尚未发出的输入',()=>box(dir=>{
86
- const modulePath=fileURLToPath(new URL('./capture-inputs.mjs',import.meta.url))
86
+ const modulePath=fileURLToPath(new URL('../capture-inputs.mjs',import.meta.url))
87
87
  const code=`import{enqueueCaptureInput}from${JSON.stringify(modulePath)};enqueueCaptureInput(process.env.INPUT_DIR,${JSON.stringify(descriptor())});process.kill(process.pid,'SIGKILL')`
88
88
  const child=spawnSync(process.execPath,['--input-type=module','-e',code],{env:{...process.env,INPUT_DIR:dir},timeout:5000})
89
89
  assert.equal(child.signal,'SIGKILL');assert.equal(pendingCaptureInputs(dir,session()).length,1)
@@ -105,7 +105,7 @@ test('清理只选已知终态,双版本过期拒绝;待处理与未决保
105
105
  }))
106
106
 
107
107
  test('输入CLI默认只读,明确清理须同时带两份版本',()=>box(dir=>{
108
- const script=fileURLToPath(new URL('./scripts/capture-jobs.mjs',import.meta.url)),env={...process.env,DSH_HOME:dir}
108
+ const script=fileURLToPath(new URL('../scripts/capture-jobs.mjs',import.meta.url)),env={...process.env,DSH_HOME:dir}
109
109
  const run=(...args)=>spawnSync(process.execPath,[script,...args],{env,encoding:'utf8',timeout:5000})
110
110
  assert.equal(run('inputs').status,0);assert.equal(existsSync(join(dir,'memory')),false)
111
111
  const store=join(dir,'memory'),row=enqueueCaptureInput(store,descriptor());blockCaptureInput(store,row.id,'source-changed')
@@ -6,10 +6,10 @@ import { tmpdir } from 'node:os'
6
6
  import { spawn, spawnSync } from 'node:child_process'
7
7
  import { createHash } from 'node:crypto'
8
8
  import { fileURLToPath } from 'node:url'
9
- import { claimCaptureJob, assertCaptureClaim, finishCaptureJob, listCaptureJobs, recoverCaptureJobs, abandonCaptureJob, retryBusyWrite } from './capture-jobs.mjs'
10
- import { withLock } from './security.mjs'
9
+ import { claimCaptureJob, assertCaptureClaim, finishCaptureJob, listCaptureJobs, recoverCaptureJobs, abandonCaptureJob, retryBusyWrite } from '../capture-jobs.mjs'
10
+ import { withLock } from '../security.mjs'
11
11
 
12
- const modulePath = fileURLToPath(new URL('./capture-jobs.mjs', import.meta.url))
12
+ const modulePath = fileURLToPath(new URL('../capture-jobs.mjs', import.meta.url))
13
13
  const hash = (v) => createHash('sha256').update(String(v)).digest('hex')
14
14
  const options = { now: 1000, leaseMs: 1000 }
15
15
  const file = (dir) => join(dir, '.capture-jobs.json')
@@ -121,7 +121,7 @@ test('两个真实进程并发领取同一快照,只有一个领取成功', as
121
121
  })
122
122
 
123
123
  test('诊断 CLI 默认只读;recover --apply 只标未决,不执行模型请求', () => box((dir) => {
124
- const script = fileURLToPath(new URL('./scripts/capture-jobs.mjs', import.meta.url))
124
+ const script = fileURLToPath(new URL('../scripts/capture-jobs.mjs', import.meta.url))
125
125
  const env = { ...process.env, DSH_HOME: dir }
126
126
  const run = (...args) => spawnSync(process.execPath, [script, ...args], { env, encoding: 'utf8', timeout: 5000 })
127
127
  assert.equal(run().status, 0); assert.equal(existsSync(join(dir, 'memory')), false)
@@ -286,7 +286,7 @@ test('两个真实进程对同一预览并发处置,仅一次生效,另一
286
286
  })
287
287
 
288
288
  test('处置 CLI 默认预览,缺版本/未知参数/过期版本不修改记录,明确应用后仍为未知结果', () => box((dir) => {
289
- const script = fileURLToPath(new URL('./scripts/capture-jobs.mjs', import.meta.url))
289
+ const script = fileURLToPath(new URL('../scripts/capture-jobs.mjs', import.meta.url))
290
290
  const run = (...args) => spawnSync(process.execPath, [script, ...args], { env: { ...process.env, DSH_HOME: dir }, encoding: 'utf8', timeout: 5000 })
291
291
  const job = claimCaptureJob(join(dir, 'memory'), hash('cli-close'), options)
292
292
  const before = readFileSync(file(join(dir,'memory')),'utf8')