solopup-memory 0.21.0-beta.2 → 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 (99) hide show
  1. package/CHANGELOG.md +37 -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 +35 -6
  6. package/README.md +44 -7
  7. package/RELEASE.md +36 -0
  8. package/SECURITY.md +11 -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 +117 -27
  15. package/managed-policy.mjs +3 -1
  16. package/package.json +91 -68
  17. package/privacy.mjs +25 -0
  18. package/scripts/demo-source-recall.mjs +123 -0
  19. package/scripts/detect.mjs +69 -0
  20. package/scripts/dream.mjs +205 -0
  21. package/scripts/probe-history.mjs +92 -0
  22. package/scripts/probe-load.mjs +1 -1
  23. package/scripts/probe-package.mjs +6 -2
  24. package/scripts/security-check.mjs +173 -0
  25. package/scripts/soak.mjs +219 -0
  26. package/scripts/telemetry.mjs +187 -0
  27. package/scripts/verify.mjs +3 -1
  28. package/{test-adversarial.mjs → test/test-adversarial.mjs} +5 -5
  29. package/{test-belief-revision.mjs → test/test-belief-revision.mjs} +7 -7
  30. package/{test-candidates.mjs → test/test-candidates.mjs} +4 -4
  31. package/{test-capture-diagnostics.mjs → test/test-capture-diagnostics.mjs} +6 -6
  32. package/{test-capture-field-lineage.mjs → test/test-capture-field-lineage.mjs} +4 -4
  33. package/{test-capture-inputs.mjs → test/test-capture-inputs.mjs} +8 -8
  34. package/{test-capture-jobs.mjs → test/test-capture-jobs.mjs} +5 -5
  35. package/{test-capture-projections.mjs → test/test-capture-projections.mjs} +12 -12
  36. package/{test-capture-revision.mjs → test/test-capture-revision.mjs} +6 -6
  37. package/{test-capture-submission.mjs → test/test-capture-submission.mjs} +3 -3
  38. package/{test-capture-validation-flow.mjs → test/test-capture-validation-flow.mjs} +5 -5
  39. package/{test-capture-validation-job.mjs → test/test-capture-validation-job.mjs} +5 -5
  40. package/{test-capture-validation-lineage.mjs → test/test-capture-validation-lineage.mjs} +2 -2
  41. package/{test-capture-validation.mjs → test/test-capture-validation.mjs} +2 -2
  42. package/{test-capture.mjs → test/test-capture.mjs} +20 -20
  43. package/{test-compaction-compat.mjs → test/test-compaction-compat.mjs} +5 -5
  44. package/{test-compat.mjs → test/test-compat.mjs} +3 -3
  45. package/{test-concurrency.mjs → test/test-concurrency.mjs} +3 -3
  46. package/{test-conflict.mjs → test/test-conflict.mjs} +2 -2
  47. package/{test-context-history.mjs → test/test-context-history.mjs} +1 -1
  48. package/{test-control-schema.mjs → test/test-control-schema.mjs} +1 -1
  49. package/test/test-detect.mjs +125 -0
  50. package/test/test-dream-plan.mjs +72 -0
  51. package/test/test-dsh-registration.mjs +111 -0
  52. package/{test-episodes.mjs → test/test-episodes.mjs} +5 -5
  53. package/{test-explicit-memory.mjs → test/test-explicit-memory.mjs} +4 -4
  54. package/{test-field-lineage.mjs → test/test-field-lineage.mjs} +4 -4
  55. package/{test-history.mjs → test/test-history.mjs} +4 -4
  56. package/{test-inline-summary.mjs → test/test-inline-summary.mjs} +5 -5
  57. package/{test-legacy-source-identity.mjs → test/test-legacy-source-identity.mjs} +2 -2
  58. package/{test-links.mjs → test/test-links.mjs} +4 -4
  59. package/{test-managed-access.mjs → test/test-managed-access.mjs} +29 -5
  60. package/{test-managed-activation.mjs → test/test-managed-activation.mjs} +5 -5
  61. package/{test-meaning.mjs → test/test-meaning.mjs} +6 -6
  62. package/{test-native-service-lifecycle.mjs → test/test-native-service-lifecycle.mjs} +1 -1
  63. package/{test-native-write-validation.mjs → test/test-native-write-validation.mjs} +10 -4
  64. package/{test-packaging.mjs → test/test-packaging.mjs} +32 -5
  65. package/{test-paired-analysis.mjs → test/test-paired-analysis.mjs} +1 -1
  66. package/{test-patch.mjs → test/test-patch.mjs} +11 -11
  67. package/{test-plugin-contract.mjs → test/test-plugin-contract.mjs} +4 -4
  68. package/test/test-privacy.mjs +65 -0
  69. package/{test-project-stores.mjs → test/test-project-stores.mjs} +5 -5
  70. package/{test-publication.mjs → test/test-publication.mjs} +4 -4
  71. package/{test-restore.mjs → test/test-restore.mjs} +11 -11
  72. package/{test-retrieval.mjs → test/test-retrieval.mjs} +2 -2
  73. package/{test-revision-admission.mjs → test/test-revision-admission.mjs} +3 -3
  74. package/{test-rule-admission.mjs → test/test-rule-admission.mjs} +8 -8
  75. package/{test-runtime-startup.mjs → test/test-runtime-startup.mjs} +2 -2
  76. package/{test-schema.mjs → test/test-schema.mjs} +2 -2
  77. package/{test-security.mjs → test/test-security.mjs} +6 -6
  78. package/test/test-semantic-suite.mjs +20 -0
  79. package/{test-service-readiness.mjs → test/test-service-readiness.mjs} +1 -1
  80. package/{test-smell.mjs → test/test-smell.mjs} +4 -4
  81. package/{test-snapshots.mjs → test/test-snapshots.mjs} +3 -3
  82. package/{test-source-completeness.mjs → test/test-source-completeness.mjs} +6 -6
  83. package/{test-source-key.mjs → test/test-source-key.mjs} +2 -2
  84. package/{test-source-retirement.mjs → test/test-source-retirement.mjs} +4 -4
  85. package/{test-status-both.mjs → test/test-status-both.mjs} +12 -4
  86. package/{test-supersede.mjs → test/test-supersede.mjs} +3 -3
  87. package/test/test-telemetry.mjs +60 -0
  88. package/{test-text-admission.mjs → test/test-text-admission.mjs} +6 -6
  89. package/{test-tool-inputs.mjs → test/test-tool-inputs.mjs} +1 -1
  90. package/{test-tool-observations.mjs → test/test-tool-observations.mjs} +6 -6
  91. package/{test-tool-write-guard.mjs → test/test-tool-write-guard.mjs} +3 -3
  92. package/{test-tool-write-lifecycle.mjs → test/test-tool-write-lifecycle.mjs} +3 -3
  93. package/{test-usability.mjs → test/test-usability.mjs} +7 -7
  94. package/{test-validation-source-refs.mjs → test/test-validation-source-refs.mjs} +2 -2
  95. package/{test-validation-transition-context.mjs → test/test-validation-transition-context.mjs} +1 -1
  96. package/{test-withdrawal-archives.mjs → test/test-withdrawal-archives.mjs} +6 -6
  97. package/{test-withdrawal.mjs → test/test-withdrawal.mjs} +2 -2
  98. package/{test.mjs → test/test.mjs} +4 -4
  99. package/version.mjs +1 -1
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 发版前安全自查(只读)。检查依赖面、运行时执行/网络面、密钥与绝对路径、
4
+ * CI 工作流权限与 pin、打包内容、许可与引擎声明。
5
+ *
6
+ * node scripts/security-check.mjs
7
+ * node scripts/security-check.mjs --json
8
+ *
9
+ * 这不是渗透测试,也不能替代独立审计;它检查的是“不该进发行包/不该在运行时出现”的机械项。
10
+ */
11
+
12
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
13
+ import { dirname, join, relative } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+
16
+ const HERE = dirname(fileURLToPath(import.meta.url))
17
+ const ROOT = join(HERE, '..')
18
+ const pkg = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8'))
19
+ const checks = []
20
+ const check = (id, run) => {
21
+ try {
22
+ const detail = run()
23
+ checks.push({ id, passed: true, detail: detail ?? null })
24
+ } catch (error) {
25
+ checks.push({ id, passed: false, error: error.message })
26
+ }
27
+ }
28
+ const assert = (condition, message) => {
29
+ if (!condition) throw new Error(message)
30
+ }
31
+
32
+ function walk(dir, out = []) {
33
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
34
+ if (['node_modules', 'research', '.git', '.github', 'ci'].includes(entry.name)) continue
35
+ const full = join(dir, entry.name)
36
+ if (entry.isDirectory()) walk(full, out)
37
+ else if (entry.isFile()) out.push(full)
38
+ }
39
+ return out
40
+ }
41
+
42
+ const files = walk(ROOT)
43
+ const runtimeFiles = files.filter((file) => file.endsWith('.mjs') && !/\/test-[^/]+\.mjs$/.test(file) && !file.includes('/scripts/') && !file.includes('/eval/'))
44
+ const text = (file) => readFileSync(file, 'utf8')
45
+
46
+ /** 发行包实际包含的文件(目录条目展开)。绝对路径与密钥检查只看这些。 */
47
+ function shippedFiles() {
48
+ const out = new Set()
49
+ for (const entry of pkg.files ?? []) {
50
+ if (entry.endsWith('/')) {
51
+ const prefix = join(ROOT, entry)
52
+ if (existsSync(prefix)) for (const file of walk(prefix)) out.add(file)
53
+ continue
54
+ }
55
+ const full = join(ROOT, entry)
56
+ if (existsSync(full) && statSync(full).isFile()) out.add(full)
57
+ }
58
+ return [...out]
59
+ }
60
+ /** 注入测试语料与安全测试里故意放的反例,不是真实凭据。 */
61
+ const fixtureAllowlist = (file) => /\/corpus\/|\/test-[^/]+\.mjs$|\/scripts\/security-check\.mjs$/.test(file)
62
+
63
+ check('dependencies', () => {
64
+ const deps = { ...(pkg.dependencies ?? {}), ...(pkg.optionalDependencies ?? {}), ...(pkg.peerDependencies ?? {}) }
65
+ assert(Object.keys(deps).length === 0, `存在运行时依赖:${Object.keys(deps).join(',')}`)
66
+ return 'zero runtime dependencies'
67
+ })
68
+
69
+ check('install-hooks', () => {
70
+ const hooks = ['preinstall', 'install', 'postinstall', 'prepare', 'prepublishOnly'].filter((name) => pkg.scripts?.[name] !== undefined)
71
+ assert(hooks.length === 0, `存在安装/发布钩子:${hooks.join(',')}`)
72
+ return 'no install hooks'
73
+ })
74
+
75
+ check('runtime-network', () => {
76
+ const hits = []
77
+ for (const file of runtimeFiles) {
78
+ const body = text(file)
79
+ if (/\bfetch\s*\(/.test(body) || /node:(http|https|net|tls)/.test(body) || /\.listen\s*\(/.test(body) || /new\s+WebSocket/.test(body)) hits.push(relative(ROOT, file))
80
+ }
81
+ assert(hits.length === 0, `运行时出现网络/监听面:${hits.join(',')}`)
82
+ return `${runtimeFiles.length} 个运行时文件无网络调用`
83
+ })
84
+
85
+ check('runtime-exec', () => {
86
+ const allowed = new Set(['restore.mjs'])
87
+ const hits = []
88
+ for (const file of runtimeFiles) {
89
+ if (!/child_process|\bexecSync\b|\bspawnSync\b/.test(text(file))) continue
90
+ if (!allowed.has(relative(ROOT, file))) hits.push(relative(ROOT, file))
91
+ }
92
+ assert(hits.length === 0, `未登记的运行时子进程:${hits.join(',')}`)
93
+ const restore = text(join(ROOT, 'restore.mjs'))
94
+ assert(/spawnSync\('tar',\s*\[/.test(restore), 'restore.mjs 的子进程不是固定的 tar 参数调用')
95
+ assert(!/shell:\s*true/.test(restore), 'restore.mjs 启用了 shell')
96
+ return 'restore.mjs 仅以固定参数调用 tar(无 shell)'
97
+ })
98
+
99
+ check('secrets-shipped', () => {
100
+ const patterns = [/gho_[A-Za-z0-9]{20,}/, /npm_[A-Za-z0-9]{20,}/, /sk-[A-Za-z0-9]{20,}/, /-----BEGIN [A-Z ]*PRIVATE KEY-----/]
101
+ const hits = []
102
+ const shipped = shippedFiles().filter((file) => !fixtureAllowlist(file))
103
+ for (const file of [...shipped, join(ROOT, '.github', 'workflows', 'publish.yml')]) {
104
+ if (!existsSync(file) || !/\.(mjs|json|md|yml|yaml|js|txt)$/.test(file)) continue
105
+ const body = text(file)
106
+ for (const pattern of patterns) if (pattern.test(body)) hits.push(`${relative(ROOT, file)}:${pattern}`)
107
+ }
108
+ assert(hits.length === 0, `发行文件里发现密钥:${hits.slice(0, 5).join(';')}`)
109
+ return `${shipped.length} 个发行文件无密钥(测试语料已列允许项)`
110
+ })
111
+
112
+ check('absolute-paths-shipped', () => {
113
+ const pattern = /\/Users\/[a-z][^/\s"']+/
114
+ const hits = shippedFiles().filter((file) => /\.(mjs|json|md|yml|yaml|js|txt)$/.test(file) && pattern.test(text(file)))
115
+ assert(hits.length === 0, `发行文件含机器绝对路径:${hits.map((file) => relative(ROOT, file)).slice(0, 5).join(',')}`)
116
+ return `${shippedFiles().length} 个发行文件无 /Users 绝对路径`
117
+ })
118
+
119
+ check('workflow-pins', () => {
120
+ const file = join(ROOT, '.github', 'workflows', 'publish.yml')
121
+ if (!existsSync(file)) return '未包含发布工作流'
122
+ const body = text(file)
123
+ const uses = [...body.matchAll(/^\s*(?:-\s*)?uses:\s*(\S+)/gm)].map((match) => match[1])
124
+ const unpinned = uses.filter((value) => !value.startsWith('./.github/workflows/') && !/@[0-9a-f]{40}$/.test(value))
125
+ assert(unpinned.length === 0, `未按 commit pin 的 action:${unpinned.join(',')}`)
126
+ assert(/permissions:\s*\n\s*contents:\s*read/.test(body), '工作流权限不是 contents: read')
127
+ assert(!/secrets\.[A-Za-z0-9_]+/.test(body), '工作流引用了 secrets(优先 OIDC/可信发布)')
128
+ assert(!/pull_request_target/.test(body), '工作流使用了 pull_request_target')
129
+ return `${uses.length} 个 action 全部 pin 到 commit;contents: read;无 secrets`
130
+ })
131
+
132
+ check('package-contents', () => {
133
+ const bad = (pkg.files ?? []).filter((entry) => /research|last-run|\.tgz|node_modules|\.env/.test(entry))
134
+ assert(bad.length === 0, `发行包包含不该发布的条目:${bad.join(',')}`)
135
+ assert(pkg.files.includes('LICENSE') || pkg.license === 'MIT', '发行包未声明 LICENSE')
136
+ return `${pkg.files.length} 个发布条目,无研究目录/日志/tgz`
137
+ })
138
+
139
+ check('license', () => {
140
+ assert(pkg.license === 'MIT', `license=${pkg.license}`)
141
+ const body = text(join(ROOT, 'LICENSE'))
142
+ assert(/MIT License/.test(body), 'LICENSE 不是 MIT 正文')
143
+ return 'MIT'
144
+ })
145
+
146
+ check('engines', () => {
147
+ assert(pkg.engines?.node, '缺少 node engines 声明')
148
+ assert(pkg.engines?.dsh, '缺少 dsh engines 声明')
149
+ return `node ${pkg.engines.node};dsh ${pkg.engines.dsh}`
150
+ })
151
+
152
+ check('gitignore', () => {
153
+ const body = existsSync(join(ROOT, '.gitignore')) ? text(join(ROOT, '.gitignore')) : ''
154
+ const need = ['node_modules/', '*.tgz']
155
+ const missing = need.filter((line) => !body.includes(line))
156
+ assert(missing.length === 0, `.gitignore 缺:${missing.join(',')}`)
157
+ return '.gitignore 覆盖 node_modules 与 tgz'
158
+ })
159
+
160
+ check('store-permissions-code', () => {
161
+ const security = text(join(ROOT, 'security.mjs'))
162
+ assert(/0o700/.test(security) && /0o600/.test(security), 'security.mjs 未显式设置 0700/0600')
163
+ return 'store 目录 0700、文件 0600(由 soak 实测再次验证)'
164
+ })
165
+
166
+ const passed = checks.every((row) => row.passed)
167
+ const report = { passed, scope: 'mechanical pre-release checks; not an independent audit', version: pkg.version, checks }
168
+ if (process.argv.includes('--json')) process.stdout.write(JSON.stringify(report, null, 2) + '\n')
169
+ else {
170
+ for (const row of checks) console.log(`[${row.passed ? 'PASS' : 'FAIL'}] ${row.id}${row.passed ? ` · ${row.detail}` : ` · ${row.error}`}`)
171
+ console.log(passed ? `安全自查通过(${checks.length} 项;机械项,不是独立审计)` : '安全自查未通过')
172
+ }
173
+ if (!passed) process.exitCode = 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: '这次我选低反差的那一张图' }] })