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
package/privacy.mjs ADDED
@@ -0,0 +1,25 @@
1
+ /** Conservative exposure reduction, not a confidentiality boundary or exhaustive PII detector. */
2
+ import {normalizeText} from './security.mjs'
3
+ const patterns = [
4
+ ['exact-dob', /生日|出生(?:日期|年月|于|在)?|\bbirth(?:day|date)\b|\bdate\s+of\s+birth\b|\bdob\b|\bborn\b/i],
5
+ ['personal-profile', /个人档案|性别|男性|女性|\bpersonal\s+profile\b|\bgender\b/i],
6
+ ['health', /病史|医疗诊断|疾病诊断|医生诊断|诊断(?:为|出)|用药|过敏|怀孕|月经|健康状况|医疗资料|\bmedical\b|\bclinical\s+diagnosis\b|\bdiagnosed\s+with\b|\bmedication\b/i],
7
+ ['intimate', /亲密关系|性取向|性生活|性行为|伴侣|男朋友|女朋友|前任|恋爱|情人|\bsexual\b|\bintimate\b|\bromantic\b/i],
8
+ ['personal-contact', /身份证|护照号码|家庭住址|私人电话|银行卡号|\bpassport\s+number\b|\bhome\s+address\b/i],
9
+ ['marked-private', /\[private(?::[a-z-]+)?\]|保密资料|私密档案|敏感个人/i],
10
+ ]
11
+
12
+ export function memoryPrivacy(value, line = '') {
13
+ const content = typeof value === 'string' ? value : value && typeof value === 'object'
14
+ ? [value.summary, value.detail, value.indexLine, JSON.stringify(value.meaning ?? {}),
15
+ ...(value.evidence ?? []).map(e => e.quote), JSON.stringify(value.fieldLineage ?? {})].filter(Boolean).join('\n') : ''
16
+ const text = normalizeText(content + '\n' + line)
17
+ const categories = patterns.filter(([, pattern]) => pattern.test(text)).map(([category]) => category)
18
+ return {exposure: categories.length ? 'on-demand' : 'automatic', categories}
19
+ }
20
+ export const privateMemory = (value, line = '') => memoryPrivacy(value, line).exposure === 'on-demand'
21
+ export function publicMemoryRecords(records) { return records.filter(record => !privateMemory(record)) }
22
+ export function publicMemoryIndex(lines, records) {
23
+ return lines.filter(line => !privateMemory(records.find(record => record.indexLine === line || line.endsWith(' ' + record.indexLine)), line))
24
+ }
25
+ export const PRIVATE_MEMORY_NOTICE = '个人资料可能按需保存在本地;未自动提供其内容。仅在用户询问相关资料时使用 memory_search 并明确 includePrivate=true。读取结果仍会进入当前会话及模型请求;这不是加密或文件访问隔离。'
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Offline, synthetic mechanics demonstration. Production tool handlers run in a
4
+ * scripted host, with a scripted validation response, NOT DSH or a real model.
5
+ * Each phase uses a separate Node process; no old conversation is replayed.
6
+ * Run without arguments for six output lines, or --json for the full receipt.
7
+ */
8
+ import assert from 'node:assert/strict'
9
+ import { spawnSync } from 'node:child_process'
10
+ import { createHash } from 'node:crypto'
11
+ import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { basename, join } from 'node:path'
14
+ import { fileURLToPath } from 'node:url'
15
+ import { apply } from '../index.mjs'
16
+ import { scriptedNativeValidationLlm } from '../fixture-host.mjs'
17
+ import { VERSION } from '../version.mjs'
18
+ import { probeEnvironment } from './probe-env.mjs'
19
+
20
+ const script = fileURLToPath(import.meta.url)
21
+ const quote = '记住:本项目的交付说明统一用中文。'
22
+ const summary = '本项目的交付说明统一用中文'
23
+ const query = '交付说明'
24
+ const sourceSession = 'synthetic-demo-write'
25
+
26
+ async function phase(name, root) {
27
+ // The internal phase runner refuses a non-demo home, including a user's home.
28
+ assert.equal(realpathSync(tmpdir()), join(realpathSync(root), 'tmp'))
29
+ assert.match(basename(root), /^solopup-readme-demo-/)
30
+ assert.equal(process.env.DSH_HOME, join(root, 'home'))
31
+ assert.ok(['write', 'recall', 'other-project'].includes(name))
32
+ const handlers = [], tools = [], disposers = []
33
+ const session = { id: name === 'write' ? sourceSession : `synthetic-demo-${name}` }
34
+ const agent = { session, options: { provider: 'scripted-demo', model: 'scripted-demo' } }
35
+ let validationCalls = 0
36
+ const scripted = scriptedNativeValidationLlm()
37
+ const ctx = {
38
+ llm: { async prepareCall(config) { validationCalls++; return scripted.prepareCall(config) } },
39
+ systemPrompt: { context() {} },
40
+ workingDirectory: { get: () => join(root, name === 'other-project' ? 'project-b' : 'project-a') },
41
+ tools: { register: tool => tools.push(tool) },
42
+ on: (event, handler) => handlers.push([event, handler]),
43
+ effect: fn => { const dispose = fn(); if (typeof dispose === 'function') disposers.push(dispose) },
44
+ }
45
+ const emit = (event, ...args) => {
46
+ for (const [registered, handler] of handlers) if (registered === event) handler(...args)
47
+ }
48
+ apply(ctx, { autoCapture: { enabled: false, timeoutMs: 2000 } })
49
+ try {
50
+ if (name === 'write') {
51
+ const message = { id: 'synthetic-owner-message', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: quote }] }
52
+ emit('agent/pre-step', { agent, turn: 1, step: 1, messages: [message] }, () => {})
53
+ emit('session/event', session, { type: 'user/message', seq: 1, data: message })
54
+ const args = { type: 'project', scope: 'project', summary, meaning: { kind: 'decision' } }
55
+ const callId = 'synthetic-demo-write-call'
56
+ emit('session/event', session, { type: 'tool/call', seq: 2, data: { callId, name: 'memory_write', arguments: JSON.stringify(args), turn: 1, step: 1 } })
57
+ const result = await tools.find(t => t.name === 'memory_write').execute(args, { agent, callId })
58
+ assert.equal(result.status, 'recorded')
59
+ assert.equal(validationCalls, 1)
60
+ return { sessionId: session.id, ownerMessage: quote, args, result, scriptedValidationCalls: validationCalls }
61
+ }
62
+ // No user-message events or old history are provided to these new processes.
63
+ const search = tools.find(t => t.name === 'memory_search')
64
+ const args = { query, mode: 'exact' }
65
+ const current = await search.execute(args, { agent })
66
+ const history = await search.execute({ ...args, view: 'history' }, { agent })
67
+ assert.equal(validationCalls, 0)
68
+ if (name === 'recall') {
69
+ assert.equal(current.hits.length, 1)
70
+ assert.equal(current.hits[0].scope, 'project')
71
+ assert.ok(current.hits[0].text.includes(summary))
72
+ assert.equal(history.hits.length, 1)
73
+ assert.equal(history.hits[0].history.sourceSessionId, sourceSession)
74
+ assert.deepEqual(history.hits[0].history.sourceMessageIds, [`${sourceSession}:1`])
75
+ assert.ok(history.hits[0].text.includes(quote))
76
+ } else {
77
+ assert.equal(current.hits.length, 0)
78
+ assert.equal(history.hits.length, 0)
79
+ }
80
+ return { sessionId: session.id, inheritedMessages: 0, args, current, history, scriptedValidationCalls: validationCalls }
81
+ } finally {
82
+ for (const dispose of disposers) dispose()
83
+ }
84
+ }
85
+
86
+ if (process.argv[2] === '--phase' && process.argv.length === 5) {
87
+ console.log(JSON.stringify(await phase(process.argv[3], process.argv[4])))
88
+ } else {
89
+ assert.ok(process.argv.length === 2 || process.argv.length === 3 && process.argv[2] === '--json', 'usage: node scripts/demo-source-recall.mjs [--json]')
90
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'solopup-readme-demo-')))
91
+ const env = probeEnvironment(root)
92
+ for (const dir of [env.HOME, env.TMPDIR, join(root, 'project-a'), join(root, 'project-b')]) mkdirSync(dir)
93
+ for (const project of ['project-a', 'project-b']) writeFileSync(join(root, project, '.dsh-root'), '')
94
+ try {
95
+ const phases = {}
96
+ for (const name of ['write', 'recall', 'other-project']) {
97
+ const child = spawnSync(process.execPath, [script, '--phase', name, root], { env, encoding: 'utf8', timeout: 30000 })
98
+ assert.equal(child.status, 0, `demo ${name} failed: ${child.stderr}`)
99
+ phases[name] = JSON.parse(child.stdout)
100
+ }
101
+ const lines = [
102
+ `synthetic user: ${quote}`,
103
+ `memory_write: ${phases.write.result.status} (scope=project, meaning=decision)`,
104
+ `fresh process / memory_search("${query}"): ${phases.recall.current.hits.length} hit (scope=${phases.recall.current.hits[0].scope})`,
105
+ `current text: ${phases.recall.current.hits[0].text}`,
106
+ `history source: ${phases.recall.history.hits[0].history.sourceMessageIds.join(', ')}; quote: ${quote}`,
107
+ `other project / current + history: ${phases['other-project'].current.hits.length} + ${phases['other-project'].history.hits.length} hits`,
108
+ ]
109
+ const report = {
110
+ kind: 'synthetic-scripted-host-mechanics',
111
+ at: new Date().toISOString(), version: VERSION,
112
+ scriptSha256: createHash('sha256').update(readFileSync(script)).digest('hex'),
113
+ environment: { platform: process.platform, arch: process.arch, node: process.version },
114
+ realDshHost: false, realModel: false, networkRequests: 0,
115
+ isolatedTemporaryHome: true, separateProcesses: 3,
116
+ limitations: 'Scripted validator approves this synthetic candidate. This verifies persistence, source return and project filtering only, not model judgment, real DSH schema registration, or natural fresh-chat recall.',
117
+ lines, phases,
118
+ }
119
+ console.log(process.argv[2] === '--json' ? JSON.stringify(report, null, 2) : lines.join('\n'))
120
+ } finally {
121
+ rmSync(root, { recursive: true, force: true })
122
+ }
123
+ }
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 记忆层检测 CLI(L0 不变量层)。只读,不调用模型。
4
+ *
5
+ * node scripts/detect.mjs # 检查 ~/.dsh/memory
6
+ * node scripts/detect.mjs --root DIR # 指定记忆根
7
+ * node scripts/detect.mjs --json # 机器可读
8
+ * node scripts/detect.mjs --check # 有 error 则退出码 1(CI 门禁)
9
+ * node scripts/detect.mjs --verbose # 连 info 一起打印
10
+ */
11
+
12
+ import { homedir } from 'node:os'
13
+ import { existsSync } from 'node:fs'
14
+ import { join, resolve } from 'node:path'
15
+
16
+ import { detectRoot } from '../detect.mjs'
17
+
18
+ const argv = process.argv.slice(2)
19
+ const flag = (name) => argv.includes(name)
20
+ const option = (name) => {
21
+ const at = argv.indexOf(name)
22
+ return at >= 0 ? argv[at + 1] : undefined
23
+ }
24
+
25
+ const root = resolve(option('--root') ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'memory'))
26
+ if (!existsSync(root)) {
27
+ console.error(`[detect] 记忆根不存在:${root}`)
28
+ process.exit(2)
29
+ }
30
+
31
+ const report = detectRoot(root)
32
+ if (flag('--json')) {
33
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n')
34
+ } else {
35
+ const lines = []
36
+ lines.push(`SoloPup 记忆检测 v${report.version} · ${report.generatedAt}`)
37
+ lines.push(`根目录:${report.root}`)
38
+ lines.push(`总计:error ${report.summary.errors} · warning ${report.summary.warnings} · info ${report.summary.infos}`)
39
+ for (const store of report.stores) {
40
+ const label = store.scope === 'global' ? 'global' : 'project'
41
+ const metrics = store.metrics
42
+ lines.push('')
43
+ lines.push(`== ${label}:${store.dir}`)
44
+ if (metrics) {
45
+ lines.push(
46
+ ` 索引 ${metrics.indexLines}/${metrics.limitLines} 行、${metrics.indexBytes}/${metrics.limitBytes} 字节;` +
47
+ `有效记录 ${metrics.activeRecords}、待审 ${metrics.pendingRecords}、过期行 ${metrics.expiredIndexLines};` +
48
+ `库占用 ${(metrics.storeBytes / 1024).toFixed(1)}K/${metrics.storeLimitBytes / 1024}K`,
49
+ )
50
+ }
51
+ if (store.findings.length === 0) {
52
+ lines.push(' [ok] 未发现问题(“未发现”不等于“没有问题”,语义质量见 L2)')
53
+ continue
54
+ }
55
+ for (const finding of store.findings) {
56
+ if (finding.severity === 'info' && !flag('--verbose')) continue
57
+ const refs = (finding.refs ?? []).map((item) => (item.line === undefined ? item.file : `${item.file}:${item.line}`)).join(' ')
58
+ lines.push(` [${finding.severity}] ${finding.id} ${finding.message}${refs ? ` (${refs})` : ''}`)
59
+ if (finding.fixHint && flag('--verbose')) lines.push(` 修复:${finding.fixHint}`)
60
+ }
61
+ }
62
+ for (const finding of report.crossFindings) {
63
+ if (finding.severity === 'info' && !flag('--verbose')) continue
64
+ lines.push(` [${finding.severity}] ${finding.id} ${finding.message}`)
65
+ }
66
+ process.stdout.write(lines.join('\n') + '\n')
67
+ }
68
+
69
+ if (flag('--check') && report.summary.errors > 0) process.exit(1)
@@ -0,0 +1,205 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 做梦 P0:只读的 op 计划生成器(影子报告)。
4
+ *
5
+ * 它读 L0 检测 findings 与 L3 遥测,把“该整理什么”写成可审计的 op 列表,
6
+ * **不写任何文件、不调用模型、不 apply**。真正的应用在 P1 之后,必须人工确认。
7
+ *
8
+ * node scripts/dream.mjs # 人类可读
9
+ * node scripts/dream.mjs --json # 机器可读
10
+ * node scripts/dream.mjs --check # 有 high 风险 op 时退出码 1
11
+ */
12
+
13
+ import { createHash } from 'node:crypto'
14
+ import { homedir } from 'node:os'
15
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
16
+ import { join, resolve } from 'node:path'
17
+
18
+ import { detectRoot, detectStore } from '../detect.mjs'
19
+ import { pruneStore } from '../index.mjs'
20
+ import { collect as collectTelemetry } from './telemetry.mjs'
21
+
22
+ const KIND_BY_FINDING = {
23
+ 'I3-record-missing-from-index': { kind: 'align-projection', risk: 'auto', why: '账本有效记录没有投影到索引' },
24
+ 'I3-detail-topic-missing': { kind: 'align-projection', risk: 'auto', why: '记录正文文件缺失' },
25
+ 'I3-detail-mismatch': { kind: 'align-projection', risk: 'auto', why: '正文与账本不一致' },
26
+ 'I2-missing-topic-file': { kind: 'repair-reference', risk: 'auto', why: '索引指向不存在的主题文件' },
27
+ 'I2-missing-anchor': { kind: 'repair-reference', risk: 'auto', why: '索引锚点在主题文件里找不到' },
28
+ 'I5-withdrawal-residue': { kind: 'republish-withdrawal', risk: 'auto', why: '撤回没有传播到索引' },
29
+ 'I6-duplicate-line': { kind: 'merge-duplicate', risk: 'human', why: '完全重复的索引行' },
30
+ 'I6-near-duplicate': { kind: 'merge-duplicate', risk: 'human', why: '相似度 ≥0.9 的近重复' },
31
+ 'I7-conflict': { kind: 'arbitrate-conflict', risk: 'human', why: '同一属性并存多个取值' },
32
+ 'I8-budget-lines': { kind: 'compact-index', risk: 'auto', why: '索引行数超限' },
33
+ 'I8-budget-bytes': { kind: 'compact-index', risk: 'auto', why: '索引字节超限' },
34
+ 'I8-budget-store': { kind: 'compact-index', risk: 'auto', why: '库占用超限' },
35
+ 'I10-version-mention': { kind: 'review-stale', risk: 'human', why: '记忆提到与当前版本不同的版本号' },
36
+ 'I14-truncated-paragraph': { kind: 'repair-topic-truncation', risk: 'human', why: '主题段落像被截断' },
37
+ 'I15-heading-without-index': { kind: 'link-heading', risk: 'auto', why: '标题没有索引指向' },
38
+ }
39
+
40
+ function opId(store, kind, evidence) {
41
+ return createHash('sha256').update(`${store}|${kind}|${evidence.join(',')}`).digest('hex').slice(0, 12)
42
+ }
43
+
44
+ export function planDream(root) {
45
+ const detection = detectRoot(root)
46
+ const telemetry = collectTelemetry(root)
47
+ const ops = []
48
+ for (const store of detection.stores) {
49
+ const storeName = store.dir === root ? 'global' : store.dir
50
+ for (const finding of store.findings) {
51
+ const mapping = KIND_BY_FINDING[finding.id]
52
+ if (mapping === undefined) continue
53
+ const evidence = [`${store.scope}:${finding.id}`, ...(finding.refs ?? []).map((ref) => `${ref.file}${ref.line === undefined ? '' : ':' + ref.line}`)]
54
+ ops.push({
55
+ opId: opId(storeName, mapping.kind, evidence),
56
+ kind: mapping.kind,
57
+ risk: mapping.risk,
58
+ store: storeName,
59
+ why: mapping.why,
60
+ evidence,
61
+ })
62
+ }
63
+ }
64
+ for (const finding of detection.crossFindings) {
65
+ if (finding.id !== 'X2-duplicate-store') continue
66
+ ops.push({
67
+ opId: opId('root', 'dedupe-store', [finding.message]),
68
+ kind: 'dedupe-store',
69
+ risk: 'human',
70
+ store: 'root',
71
+ why: '内容完全相同的库(遗留副本)',
72
+ evidence: [finding.id, finding.message],
73
+ })
74
+ }
75
+ for (const warning of telemetry.warnings) {
76
+ if (!['T1-low-capture-success', 'T2-zero-output', 'T3-short-sessions'].includes(warning.id)) continue
77
+ ops.push({
78
+ opId: opId('root', 'inspect-capture', [warning.id]),
79
+ kind: 'inspect-capture',
80
+ risk: 'human',
81
+ store: 'root',
82
+ why: warning.message,
83
+ evidence: [warning.id],
84
+ })
85
+ }
86
+ const summary = {
87
+ total: ops.length,
88
+ auto: ops.filter((op) => op.risk === 'auto').length,
89
+ human: ops.filter((op) => op.risk === 'human').length,
90
+ byKind: Object.fromEntries([...new Set(ops.map((op) => op.kind))].sort().map((kind) => [kind, ops.filter((op) => op.kind === kind).length])),
91
+ }
92
+ return {
93
+ kind: 'dream-plan',
94
+ phase: 'P0-shadow',
95
+ readOnly: true,
96
+ applySupported: false,
97
+ modelCalls: 0,
98
+ generatedAt: new Date().toISOString(),
99
+ root,
100
+ summary,
101
+ ops,
102
+ detectionSummary: detection.summary,
103
+ telemetrySummary: { capture: telemetry.capture, output: telemetry.output, warnings: telemetry.warnings.length },
104
+ }
105
+ }
106
+
107
+ /** P1 只自动执行确定性与可回滚的整理;其余 op 明确留给人工。 */
108
+ const AUTO_KINDS = new Set(['compact-index'])
109
+
110
+ export function planHash(plan) {
111
+ return createHash('sha256')
112
+ .update(JSON.stringify({ root: plan.root, ops: plan.ops.map((op) => ({ opId: op.opId, kind: op.kind, store: op.store, evidence: op.evidence })) }))
113
+ .digest('hex')
114
+ }
115
+
116
+ function storeDir(root, store) {
117
+ return store === 'global' ? root : store
118
+ }
119
+
120
+ function backupStore(root, dir, stamp) {
121
+ const target = join(root, '.dream', 'backups', stamp, dir === root ? 'global' : dir.slice(dir.lastIndexOf('/') + 1))
122
+ mkdirSync(target, { recursive: true })
123
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
124
+ if (!entry.isFile() || entry.name.startsWith('.')) continue
125
+ copyFileSync(join(dir, entry.name), join(target, entry.name))
126
+ }
127
+ return target
128
+ }
129
+
130
+ export function applyDream(plan, reviewed) {
131
+ if (reviewed !== planHash(plan)) throw new Error('dream-review-hash-mismatch')
132
+ const applyOps = plan.ops.filter((op) => AUTO_KINDS.has(op.kind))
133
+ const skipped = plan.ops.filter((op) => !AUTO_KINDS.has(op.kind)).map((op) => ({ opId: op.opId, kind: op.kind, reason: 'needs-human' }))
134
+ if (applyOps.length === 0) return { applied: 0, skipped, results: [], backup: null }
135
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-')
136
+ const dirs = [...new Set(applyOps.map((op) => storeDir(plan.root, op.store)))]
137
+ const before = dirs.map((dir) => detectStore(dir, { scope: dir === plan.root ? 'global' : 'project' }))
138
+ const backup = {}
139
+ for (const dir of dirs) backup[dir] = backupStore(plan.root, dir, stamp)
140
+ const results = []
141
+ for (const dir of dirs) {
142
+ const result = pruneStore(dir, { apply: true })
143
+ results.push({ dir, before: result.before, after: result.after, removed: result.removed, archived: result.archived })
144
+ }
145
+ const verification = dirs.map((dir, index) => {
146
+ const after = detectStore(dir, { scope: dir === plan.root ? 'global' : 'project' })
147
+ return { dir, errorsBefore: before[index].summary.errors, errorsAfter: after.summary.errors, ok: after.summary.errors <= before[index].summary.errors }
148
+ })
149
+ const applied = results.reduce((sum, row) => sum + (row.removed.expired + row.removed.duplicate + row.removed.overCap > 0 ? 1 : 0), 0)
150
+ return { applied: results.length, skipped, results, backup, verification }
151
+ }
152
+
153
+ if (process.argv[1] !== undefined && import.meta.url === `file://${process.argv[1]}`) {
154
+ const argv = process.argv.slice(2)
155
+ const option = (name) => {
156
+ const at = argv.indexOf(name)
157
+ return at >= 0 ? argv[at + 1] : undefined
158
+ }
159
+ const root = resolve(option('--root') ?? join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'memory'))
160
+ if (!existsSync(root)) {
161
+ console.error(`[dream] 记忆根不存在:${root}`)
162
+ process.exit(2)
163
+ }
164
+ const plan = planDream(root)
165
+ if (argv.includes('--apply')) {
166
+ const reviewed = option('--reviewed')
167
+ if (reviewed === undefined) {
168
+ console.error('[dream] --apply 需要 --reviewed <planHash>;先看 `--json` 输出的完整计划')
169
+ process.exit(2)
170
+ }
171
+ let result
172
+ try {
173
+ result = applyDream(plan, reviewed)
174
+ } catch (error) {
175
+ console.error(`[dream] 应用失败:${error.message}`)
176
+ process.exit(2)
177
+ }
178
+ if (argv.includes('--json')) process.stdout.write(JSON.stringify({ planHash: planHash(plan), ...result }, null, 2) + '\n')
179
+ else {
180
+ const lines = [
181
+ `做梦 P1 应用 · ${new Date().toISOString()}`,
182
+ `planHash:${planHash(plan)}`,
183
+ `执行 ${result.applied} 个自动 op;跳过 ${result.skipped.length} 个(需人工)。`,
184
+ ]
185
+ for (const row of result.results) lines.push(` ${row.dir}:${row.before.lines} → ${row.after.lines} 行,remove expired ${row.removed.expired} / duplicate ${row.removed.duplicate} / overCap ${row.removed.overCap}`)
186
+ for (const row of result.verification ?? []) lines.push(` 验证 ${row.dir}:error ${row.errorsBefore} → ${row.errorsAfter} ${row.ok ? 'OK' : '恶化,请用备份回滚'}`)
187
+ if (result.backup !== null) lines.push(` 备份:${Object.values(result.backup).join('、')}`)
188
+ process.stdout.write(lines.join('\n') + '\n')
189
+ }
190
+ process.exit((result.verification ?? []).every((row) => row.ok) ? 0 : 1)
191
+ }
192
+ if (argv.includes('--json')) process.stdout.write(JSON.stringify(plan, null, 2) + '\n')
193
+ else {
194
+ const lines = [
195
+ `做梦 P0(影子报告,只读)· ${plan.generatedAt}`,
196
+ `根目录:${plan.root}`,
197
+ `op 总数 ${plan.summary.total}(自动可执行候选 ${plan.summary.auto}、需人工确认 ${plan.summary.human})`,
198
+ `分类:${Object.entries(plan.summary.byKind).map(([kind, count]) => `${kind} ${count}`).join('、') || '无'}`,
199
+ '边界:不写文件、不调用模型;apply 未实现。',
200
+ ]
201
+ for (const op of plan.ops) lines.push(` [${op.risk}] ${op.kind} · ${op.why}${op.evidence.length ? `(${op.evidence.slice(0, 3).join(';')})` : ''}`)
202
+ process.stdout.write(lines.join('\n') + '\n')
203
+ }
204
+ if (argv.includes('--check') && plan.summary.human > 0) process.exit(1)
205
+ }
@@ -0,0 +1,92 @@
1
+ #!/usr/bin/env node
2
+ /** Real DSH CLI/Loader, Session and Projection API check. Synthetic input, no model calls. */
3
+ import { spawn } from 'node:child_process'
4
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, realpathSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
8
+ import { probeEnvironment } from './probe-env.mjs'
9
+
10
+ const CLI = process.env.DSH_CLI ?? '/Applications/DeepSeek Harness.app/Contents/Resources/runtime/cli/bin/dsh'
11
+ if (!existsSync(CLI)) { console.error('[FAIL] DSH_CLI must identify an installed official host'); process.exit(2) }
12
+ const source = dirname(dirname(fileURLToPath(import.meta.url)))
13
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-history-probe-')))
14
+ const env = probeEnvironment(root), profile = join(env.HOME, 'profiles', 'probe')
15
+ mkdirSync(profile, { recursive: true }); mkdirSync(env.TMPDIR)
16
+ const reportFile = join(root, 'result.json'), driver = join(root, 'driver.mjs')
17
+ writeFileSync(driver, `
18
+ import assert from 'node:assert/strict'
19
+ import { readFileSync, writeFileSync } from 'node:fs'
20
+ import * as memory from ${JSON.stringify(pathToFileURL(join(source, 'index.mjs')).href)}
21
+ import { memoryContextProjection, reconcileMemoryContext } from ${JSON.stringify(pathToFileURL(join(source, 'context-history.mjs')).href)}
22
+ export const name = 'solopup-history-probe'
23
+ export const inject = ['sessions', 'sessionProjections', 'systemPrompt', 'workingDirectory', 'tools']
24
+ export async function apply(ctx) {
25
+ try {
26
+ const owner = await ctx.plugin(memory, { autoCapture: { enabled: false } })
27
+ if (typeof owner?.await === 'function') await owner.await()
28
+ const stamp = JSON.parse(readFileSync(${JSON.stringify(join(env.HOME, 'memory', '.loaded.json'))}, 'utf8'))
29
+ assert.equal(stamp.memoryHistory.available, true)
30
+ assert.equal(stamp.tools.length, 6)
31
+ const key = 'solopup:synthetic-projection-probe'
32
+ const definition = { ...memoryContextProjection(), key }
33
+ const register = async () => {
34
+ const value = await ctx.sessionProjections.register(definition)
35
+ return typeof value?.stateOf === 'function' ? { ...value, retained: true } : { stateOf: session => ctx.sessionProjections.stateOf(session, key), dispose: value, retained: false }
36
+ }
37
+ const handle = await register()
38
+ const session = ctx.sessions.create('solopup-synthetic-history')
39
+ const ordinary = { id: 'ordinary', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'ordinary synthetic quotation remains' }] }
40
+ session.append('user/message', ordinary, { surfaceOp: 'append' })
41
+ const sections = [{ name: 'memory:index', text: 'obsolete synthetic memory' }, { name: 'other:context', text: 'keep other provider' }]
42
+ const old = { id: 'old-context', role: 'user', source: { kind: 'runtime-context', form: 'snapshot', sections }, content: [{ type: 'text', text: 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\\n\\n' + sections.map(s => s.text).join('\\n\\n') }] }
43
+ const event = session.append('user/message', old, { surfaceOp: 'append' })
44
+ assert.deepEqual(handle.stateOf(session).nodes, [{ id: 'old-context', seq: event.seq }])
45
+ assert.equal(reconcileMemoryContext(session, handle.stateOf(session), 'current synthetic memory').replaced, 1)
46
+ assert.deepEqual(session.deriveMessages()[0], ordinary)
47
+ assert.doesNotMatch(JSON.stringify(session.deriveMessages()), /obsolete synthetic memory/)
48
+ assert.match(JSON.stringify(session.deriveMessages()), /keep other provider/)
49
+ assert.deepEqual(handle.stateOf(session).nodes, [])
50
+ await handle.dispose()
51
+ // A registration acquired after events exist must reconstruct from the real Session.
52
+ const replay = await register()
53
+ assert.deepEqual(replay.stateOf(session).nodes, [])
54
+ const replayed = ctx.sessions.create('solopup-synthetic-replay', { seed: [{ ...event, seq: 0 }] })
55
+ assert.equal(replay.stateOf(replayed).nodes.length, 1)
56
+ await replay.dispose()
57
+ if (replay.retained) assert.equal(replay.stateOf(replayed).nodes.length, 1, 'retained readers remain usable after owner disposal')
58
+ await assert.rejects(async () => await ctx.sessionProjections.register({ ...definition, stateVersion: -1 }), /non-negative/)
59
+ await owner.dispose()
60
+ writeFileSync(${JSON.stringify(reportFile)}, JSON.stringify({ passed: true, realDshHost: true, realModel: false, cleanHome: true, syntheticInput: true, tools: stamp.tools.length, projectionApi: handle.retained ? 'retained-handle' : 'legacy-registry', historyAvailable: stamp.memoryHistory.available, liveState: true, rewritePreservesOrdinaryAndOtherProvider: true, coldReplay: true, registrationRejected: true, ownerDisposed: true }) + '\\n')
61
+ } catch (error) {
62
+ writeFileSync(${JSON.stringify(reportFile)}, JSON.stringify({ passed: false, error: error.stack }) + '\\n')
63
+ }
64
+ }
65
+ `)
66
+ writeFileSync(join(profile, 'package.json'), JSON.stringify({ name: 'solopup-history-probe', private: true, dependencies: {}, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'] } } }))
67
+ writeFileSync(join(profile, 'cordis.yml'), '[]\n')
68
+ writeFileSync(join(profile, 'cordis.patch.yml'), `- insert:\n - id: history-probe\n name: ${JSON.stringify(pathToFileURL(driver).href)}\n`)
69
+ let output = '', child
70
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
71
+ try {
72
+ child = spawn(CLI, ['--profile', 'probe', '--port', '0', '--no-open'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
73
+ child.stdout.on('data', chunk => { output += chunk }); child.stderr.on('data', chunk => { output += chunk })
74
+ let childError
75
+ child.once('error', error => { childError = error.code })
76
+ const deadline = Date.now() + 40000
77
+ while (!existsSync(reportFile) && !childError && child.exitCode === null && Date.now() < deadline) await sleep(100)
78
+ if (!existsSync(reportFile)) throw Error(`history-probe-host-failed:${childError ?? child.exitCode ?? 'timeout'}\n${output.slice(-6000)}`)
79
+ const report = JSON.parse(readFileSync(reportFile, 'utf8'))
80
+ if (!report.passed) throw Error(report.error + '\n' + output.slice(-6000))
81
+ console.log('[PASS] Real DSH history registration, live state, rewrite, cold replay, rejection and disposal')
82
+ console.log(JSON.stringify(report))
83
+ } catch (error) {
84
+ console.error('[FAIL] ' + error.message); process.exitCode = 1
85
+ } finally {
86
+ if (child && child.exitCode === null && child.signalCode === null) {
87
+ const stopped = new Promise(resolve => child.once('exit', resolve))
88
+ child.kill('SIGTERM'); await Promise.race([stopped, sleep(1500)])
89
+ if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); await Promise.race([stopped, sleep(1500)]) }
90
+ }
91
+ rmSync(root, { recursive: true, force: true })
92
+ }
@@ -84,5 +84,5 @@ if (missing.length > 0) {
84
84
  console.error(`[FAIL] 少注册了工具:${missing.join('、')}`)
85
85
  process.exit(1)
86
86
  }
87
- if (stamp.sourceControl?.state !== 'managed' || stamp.degraded?.length) { console.error('[FAIL] 工具存在但来源控制未就绪'); process.exit(1) }
87
+ if (stamp.sourceControl?.state !== 'managed' || stamp.degraded?.length || stamp.memoryHistory?.available !== true) { console.error('[FAIL] 工具存在但来源控制未就绪'); process.exit(1) }
88
88
  console.log(`[PASS] 隔离实例加载成功:v${stamp.version},${stamp.tools.length} 个工具,pid ${stamp.pid}`)
@@ -49,6 +49,10 @@ try {
49
49
  report.lockContention = testOutput.split('\n').filter((line) => /accepted=40; lock-busy retries=/.test(line))
50
50
  if (!Number.isInteger(report.packagedTests) || report.packagedTests < 1) throw new Error('package-probe-test-count-missing')
51
51
 
52
+ const historyOutput = run(process.execPath, ['scripts/probe-history.mjs'], unpacked, { env: { ...cleanEnv, DSH_CLI: CLI } })
53
+ report.historyContract = JSON.parse(historyOutput.trim().split('\n').at(-1))
54
+ if (!report.historyContract.passed) throw new Error('package-probe-history-contract-failed')
55
+
52
56
  // Entire profile is authored here; no copy of the user's desktop configuration.
53
57
  const home = join(root, 'home'); const profile = join(home, 'profiles', 'clean')
54
58
  mkdirSync(profile, { recursive: true })
@@ -71,8 +75,8 @@ try {
71
75
  }
72
76
  if (!stamp) throw new Error(`package-probe-host-load-failed:${hostError ?? host.exitCode ?? 'timeout'}`)
73
77
  const expected = ['memory_write', 'memory_forget', 'memory_links', 'memory_search', 'memory_prune', 'memory_status']
74
- if (stamp.version !== pkg.version || expected.some((tool) => !stamp.tools.includes(tool)) || stamp.degraded.length || stamp.sourceControl?.state !== 'managed') throw new Error('package-probe-host-registration-failed')
75
- report.hostLoad = { version: stamp.version, node: stamp.node, tools: stamp.tools, cleanHome: true, profileCopied: false, sourceControl: stamp.sourceControl,
78
+ if (stamp.version !== pkg.version || expected.some((tool) => !stamp.tools.includes(tool)) || stamp.degraded.length || stamp.sourceControl?.state !== 'managed' || stamp.memoryHistory?.available !== true) throw new Error('package-probe-host-registration-failed')
79
+ report.hostLoad = { version: stamp.version, node: stamp.node, tools: stamp.tools, cleanHome: true, profileCopied: false, sourceControl: stamp.sourceControl, memoryHistory: stamp.memoryHistory,
76
80
  environmentAllowlist: Object.keys(cleanEnv).sort(), parentEnvironmentInherited: false }
77
81
  const retained = join(home, 'memory', 'MEMORY.md')
78
82
  const syntheticMemory = '- [user] 合成验收偏好只用于打包检查 — user.md\n'