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
@@ -1,17 +1,17 @@
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{readFileSync,mkdtempSync,realpathSync,mkdirSync,rmSync,existsSync}from'node:fs'
5
5
  import{join}from'node:path'
6
6
  import{tmpdir}from'node:os'
7
- import{captureProjection}from'./capture-projections.mjs'
8
- import{candidateDetail}from'./withdrawal.mjs'
9
- import{buildUserPrompt,extractWithLlm}from'./capture-llm.mjs'
10
- import{captureInputDescriptor,reconstructCaptureInput,enqueueCaptureInput,bindCaptureInput,listCaptureInputs,blockCaptureInput}from'./capture-inputs.mjs'
11
- import{claimCaptureJob,recoverCaptureJobs,listCaptureJobs}from'./capture-jobs.mjs'
12
- import {indexLine, searchStore, storeRevision, withdrawManagedSource} from './index.mjs'
13
- import{writeFixtureMemory,prepareMountedFixture}from'./fixture-host.mjs'
14
- import{candidateSnapshot}from'./candidates.mjs'
7
+ import{captureProjection}from'../capture-projections.mjs'
8
+ import{candidateDetail}from'../withdrawal.mjs'
9
+ import{buildUserPrompt,extractWithLlm}from'../capture-llm.mjs'
10
+ import{captureInputDescriptor,reconstructCaptureInput,enqueueCaptureInput,bindCaptureInput,listCaptureInputs,blockCaptureInput}from'../capture-inputs.mjs'
11
+ import{claimCaptureJob,recoverCaptureJobs,listCaptureJobs}from'../capture-jobs.mjs'
12
+ import {indexLine, searchStore, storeRevision, withdrawManagedSource} from '../index.mjs'
13
+ import{writeFixtureMemory,prepareMountedFixture}from'../fixture-host.mjs'
14
+ import{candidateSnapshot}from'../candidates.mjs'
15
15
  const record={id:'a'.repeat(64),type:'user',scope:'global',summary:'只对初稿用标记 jade26',meaning:{kind:'preference',conditions:[]},detail:'Synthetic detail restricted to drafts.',origin:'tool-context',sessionId:'synthetic',evidence:[{messageId:'synthetic:1',quote:'Synthetic draft-only source'}]}
16
16
  const line='[global] - [user] 只对初稿用标记 jade26 — user.md'
17
17
  test('projection exposes complete existing fields and source references as data, not a new message',()=>{
@@ -28,7 +28,7 @@ test('structured projections fit the input byte budget atomically and report omi
28
28
  for(const limit of [1024,2048,24576,65536]){const prompt=buildUserPrompt([{id:'source',role:'user',text:'原话只对初稿适用'}],[line],3,limit,[],projected);assert.ok(Buffer.byteLength(prompt)<=limit,`budget ${limit}`);assert.match(prompt,/省略/);for(const row of prompt.split('\n').filter(x=>x.startsWith('{')))assert.doesNotThrow(()=>JSON.parse(row))}
29
29
  })
30
30
  test('the actual v25 descriptor cannot resume under a changed extraction contract',()=>{
31
- const f=JSON.parse(readFileSync(new URL('./corpus/capture-contract-v25.json',import.meta.url))),session={...f.session,surface:{nodes:[1]},isOwnSeq:n=>n===1},events=[];events[1]=f.event
31
+ const f=JSON.parse(readFileSync(new URL('../corpus/capture-contract-v25.json',import.meta.url))),session={...f.session,surface:{nodes:[1]},isOwnSeq:n=>n===1},events=[];events[1]=f.event
32
32
  const next=captureInputDescriptor(session,[f.message],f.cwd,f.options,f.route)
33
33
  assert.notEqual(next.configurationHash,f.descriptor.configurationHash);assert.notEqual(next.id,f.descriptor.id)
34
34
  assert.throws(()=>reconstructCaptureInput(f.descriptor,session,events,f.cwd,f.options,f.route),/configuration-changed/)
@@ -62,7 +62,7 @@ for(const mode of ['repair','stale','withdrawn'])test('live capture projection a
62
62
  })
63
63
 
64
64
  for(const status of ['pending','bound','blocked'])test('changed contract cannot create another durable invocation for the same owner sources: '+status,()=>{
65
- const f=JSON.parse(readFileSync(new URL('./corpus/capture-contract-v25.json',import.meta.url))),root=mkdtempSync(join(tmpdir(),'dsh-contract-alias-'))
65
+ const f=JSON.parse(readFileSync(new URL('../corpus/capture-contract-v25.json',import.meta.url))),root=mkdtempSync(join(tmpdir(),'dsh-contract-alias-'))
66
66
  try{
67
67
  enqueueCaptureInput(root,f.descriptor,{now:100})
68
68
  if(status==='bound'){const job=claimCaptureJob(root,f.descriptor.id,{now:100,leaseMs:1000});bindCaptureInput(root,f.descriptor.id,job.id,{now:101});recoverCaptureJobs(root,{now:2000})}
@@ -73,6 +73,6 @@ for(const status of ['pending','bound','blocked'])test('changed contract cannot
73
73
  }finally{rmSync(root,{recursive:true,force:true})}
74
74
  })
75
75
  test('genuine new owner source can create a new durable input under the new contract',()=>{
76
- const f=JSON.parse(readFileSync(new URL('./corpus/capture-contract-v25.json',import.meta.url))),root=mkdtempSync(join(tmpdir(),'dsh-contract-new-'))
76
+ const f=JSON.parse(readFileSync(new URL('../corpus/capture-contract-v25.json',import.meta.url))),root=mkdtempSync(join(tmpdir(),'dsh-contract-new-'))
77
77
  try{enqueueCaptureInput(root,f.descriptor,{now:100});const next=captureInputDescriptor(f.session,[{...f.message,seq:2,id:f.session.id+':2'}],f.cwd,f.options,f.route);assert.equal(enqueueCaptureInput(root,next,{now:101}).id,next.id);assert.equal(listCaptureInputs(root).inputs.length,2)}finally{rmSync(root,{recursive:true,force:true})}
78
78
  })
@@ -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,existsSync,rmSync} from 'node:fs'
5
5
  import {join} from 'node:path'
6
6
  import {tmpdir} from 'node:os'
7
- import {captureRevision} from './capture-revision.mjs'
8
- import {parseCandidates} from './capture-llm.mjs'
9
- import {indexLine, lookupLinks, storeDirFor} from './index.mjs'
10
- import {prepareMountedFixture,writeFixtureMemory} from './fixture-host.mjs'
11
- import {listCandidates} from './candidates.mjs'
7
+ import {captureRevision} from '../capture-revision.mjs'
8
+ import {parseCandidates} from '../capture-llm.mjs'
9
+ import {indexLine, lookupLinks, storeDirFor} from '../index.mjs'
10
+ import {prepareMountedFixture,writeFixtureMemory} from '../fixture-host.mjs'
11
+ import {listCandidates} from '../candidates.mjs'
12
12
 
13
13
  const oldSummary='个人长期默认配色使用 violet-4826',newSummary='个人长期默认配色使用 silver-5937',otherSummary='日常答复保持简洁且先给结论'
14
14
  const oldLine=indexLine('user',oldSummary),target='[global] '+oldLine
@@ -1,8 +1,8 @@
1
1
  import {test} from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import {CaptureSubmission,CAPTURE_SUBMISSION_NAME,CAPTURE_SUBMISSION_TOOL} from './capture-submission.mjs'
4
- import {extractWithLlm,CAPTURE_REQUEST_CONTRACT} from './capture-llm.mjs'
5
- import {TEXT_MEMORY_POLICY} from './memory-policy.mjs'
3
+ import {CaptureSubmission,CAPTURE_SUBMISSION_NAME,CAPTURE_SUBMISSION_TOOL} from '../capture-submission.mjs'
4
+ import {extractWithLlm,CAPTURE_REQUEST_CONTRACT} from '../capture-llm.mjs'
5
+ import {TEXT_MEMORY_POLICY} from '../memory-policy.mjs'
6
6
  const candidate={type:'user',summary:'长期固定搭档负责声音,本人负责剪辑',scope:'global',basis:'explicit',meaning:{kind:'claim'},evidence:[{messageId:'synthetic-user',quote:'我的固定搭档负责声音,我本人负责剪辑'}]}
7
7
  function chunks(entries=[candidate],options={}){
8
8
  const raw=options.raw??JSON.stringify({entries}),name=options.name??CAPTURE_SUBMISSION_NAME,id='synthetic-submit'
@@ -3,11 +3,11 @@ import assert from 'node:assert/strict'
3
3
  import {mkdtempSync,realpathSync,mkdirSync,readFileSync,rmSync,existsSync} from 'node:fs'
4
4
  import {tmpdir} from 'node:os'
5
5
  import {join} from 'node:path'
6
- import {apply,indexLine} from './index.mjs'
7
- import {writeFixtureMemory,prepareMountedFixture} from './fixture-host.mjs'
8
- import {candidateSnapshot,listCandidates} from './candidates.mjs'
9
- import {listCaptureJobs} from './capture-jobs.mjs'
10
- import {VALIDATION_FIELDS} from './capture-validation-contract.mjs'
6
+ import {apply,indexLine} from '../index.mjs'
7
+ import {writeFixtureMemory,prepareMountedFixture} from '../fixture-host.mjs'
8
+ import {candidateSnapshot,listCandidates} from '../candidates.mjs'
9
+ import {listCaptureJobs} from '../capture-jobs.mjs'
10
+ import {VALIDATION_FIELDS} from '../capture-validation-contract.mjs'
11
11
  for(const mode of ['approved','rejected','uncertain','validation-error','correction-rejected','transition-rejected','stale'])test('actual automatic acquisition → durable validation → source-bound admission: '+mode,async()=>{
12
12
  const base=realpathSync(mkdtempSync(join(tmpdir(),'dsh-validated-flow-'))),home=join(base,'home'),cwd=join(base,'project'),root=join(home,'memory'),previous=process.env.DSH_HOME
13
13
  mkdirSync(home);mkdirSync(cwd);process.env.DSH_HOME=home
@@ -3,10 +3,10 @@ import assert from 'node:assert/strict'
3
3
  import {mkdtempSync,readFileSync,rmSync} from 'node:fs'
4
4
  import {tmpdir} from 'node:os'
5
5
  import {join} from 'node:path'
6
- import {runCandidateValidation} from './capture-validation-job.mjs'
7
- import {VALIDATION_FIELDS} from './capture-validation-contract.mjs'
8
- import {listCaptureJobs,claimCaptureJob,finishCaptureJob,captureJobStatus} from './capture-jobs.mjs'
9
- import {buildCaptureDiagnostic} from './capture-diagnostics.mjs'
6
+ import {runCandidateValidation} from '../capture-validation-job.mjs'
7
+ import {VALIDATION_FIELDS} from '../capture-validation-contract.mjs'
8
+ import {listCaptureJobs,claimCaptureJob,finishCaptureJob,captureJobStatus} from '../capture-jobs.mjs'
9
+ import {buildCaptureDiagnostic} from '../capture-diagnostics.mjs'
10
10
  import {writeFileSync} from 'node:fs'
11
11
  import {spawn,spawnSync} from 'node:child_process'
12
12
  import {existsSync} from 'node:fs'
@@ -41,7 +41,7 @@ test('a source/revision change during validation propagates and prevents a publi
41
41
  assert.equal(calls,1);assert.equal(listCaptureJobs(dir).jobs[0].status,'failed')
42
42
  }))
43
43
  test('SIGKILL after a validation invocation is claimed cannot resend that child from a new process',()=>box(async dir=>{
44
- const marker=join(dir,'invocation-started'),moduleUrl=new URL('./capture-validation-job.mjs',import.meta.url).href
44
+ const marker=join(dir,'invocation-started'),moduleUrl=new URL('../capture-validation-job.mjs',import.meta.url).href
45
45
  const opts={jobDir:dir,parentId:'e'.repeat(64),messages:[message],candidates:[candidate],sessionId:'synthetic-crash',timeoutMs:5000}
46
46
  const script=`import{runCandidateValidation}from${JSON.stringify(moduleUrl)};import{writeFileSync}from'node:fs';let calls=0;let ticker;const opts=${JSON.stringify(opts)};opts.llm={async prepareCall(config){return{config,async*stream(){calls++;writeFileSync(${JSON.stringify(marker)},'started');if(process.argv[1]==='issue'){ticker=setInterval(()=>{},1000);await new Promise(()=>{})}yield{type:'text-delta',text:'{"checks":[]}'};yield{type:'finish',reason:{kind:'stop'}}}}}};const r=await runCandidateValidation(opts);clearInterval(ticker);console.log(JSON.stringify({calls,reason:r.reason}));`
47
47
  const child=spawn(process.execPath,['--input-type=module','-e',script,'issue'],{stdio:['ignore','pipe','pipe']})
@@ -1,4 +1,4 @@
1
- import {VALIDATION_FIELDS} from './capture-validation-contract.mjs'
1
+ import {VALIDATION_FIELDS} from '../capture-validation-contract.mjs'
2
2
  /** Scripted pipeline regression; optional source override reproduces the frozen dev28 bug. */
3
3
  import {test} from 'node:test'
4
4
  import assert from 'node:assert/strict'
@@ -6,7 +6,7 @@ import {mkdtempSync,realpathSync,mkdirSync,readFileSync,rmSync} from 'node:fs'
6
6
  import {join,resolve} from 'node:path'
7
7
  import {tmpdir} from 'node:os'
8
8
  import {pathToFileURL} from 'node:url'
9
- const baseUrl=process.env.DSH_VALIDATION_LINEAGE_SOURCE?pathToFileURL(resolve(process.env.DSH_VALIDATION_LINEAGE_SOURCE)+'/'):new URL('./',import.meta.url)
9
+ const baseUrl=process.env.DSH_VALIDATION_LINEAGE_SOURCE?pathToFileURL(resolve(process.env.DSH_VALIDATION_LINEAGE_SOURCE)+'/'):new URL('../',import.meta.url)
10
10
  const {apply,withdrawManagedSource,searchStore}=await import(new URL('index.mjs',baseUrl))
11
11
  const {candidateSnapshot}=await import(new URL('candidates.mjs',baseUrl))
12
12
  const {writeFixtureMemory}=await import(new URL('fixture-host.mjs',baseUrl))
@@ -1,7 +1,7 @@
1
1
  import {test} from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import {prepareValidationPrompt,ValidationSubmission,validateCandidatesWithLlm,validationCandidateId} from './capture-validation.mjs'
4
- import {VALIDATION_FIELDS,VALIDATION_NAME} from './capture-validation-contract.mjs'
3
+ import {prepareValidationPrompt,ValidationSubmission,validateCandidatesWithLlm,validationCandidateId} from '../capture-validation.mjs'
4
+ import {VALIDATION_FIELDS,VALIDATION_NAME} from '../capture-validation-contract.mjs'
5
5
  const message={id:'owner-1',role:'user',text:'我在所有项目处理反馈时都先看最重要的两处问题,然后再看怎么改。'}
6
6
  const candidate={type:'user',scope:'global',basis:'explicit',summary:'处理反馈先看两处问题再给修改办法,跨项目通用',meaning:{kind:'preference',conditions:[{dimension:'task',value:'review'}]},evidence:[{messageId:message.id,quote:message.text}]}
7
7
  const prepared=()=>prepareValidationPrompt([message],[candidate])
@@ -1,8 +1,8 @@
1
- import {applyWithValidationFixture as apply} from './fixture-host.mjs'
2
- import {prepareMountedFixture,writeFixtureMemory} from './fixture-host.mjs'
1
+ import {applyWithValidationFixture as apply} from '../fixture-host.mjs'
2
+ import {prepareMountedFixture,writeFixtureMemory} from '../fixture-host.mjs'
3
3
  import {realpathSync} from 'node:fs'
4
4
  /**
5
- * 自动捕获的测试。跑法:node --test ~/.dsh/plugins/memory/test-capture.mjs
5
+ * 自动捕获的测试。跑法:node --test ~/.dsh/plugins/memory/test/test-capture.mjs
6
6
  *
7
7
  * 这里用假 LLM 验证「提示词 → 解析 → 过闸 → 落库 → 审计」整条链路,
8
8
  * 不产生真实模型调用。
@@ -15,13 +15,13 @@ import { join, dirname } from 'node:path'
15
15
  import { tmpdir } from 'node:os'
16
16
  import { fileURLToPath } from 'node:url'
17
17
 
18
- import { chunkText, parseCandidates, buildUserPrompt, extractWithLlm, selectCaptureControls } from './capture-llm.mjs'
19
- import { captureMessage, assessCandidate } from './capture-evidence.mjs'
20
- import { listCaptureJobs, recoverCaptureJobs, abandonCaptureJob } from './capture-jobs.mjs'
21
- import { listCandidates, stageCandidate, candidateSnapshot } from './candidates.mjs'
22
- import {writeMemory, indexLine, storeDirFor, storeRevision, forgetMemory, adoptCandidate, withdrawManagedSource, searchStore} from './index.mjs'
18
+ import { chunkText, parseCandidates, buildUserPrompt, extractWithLlm, selectCaptureControls } from '../capture-llm.mjs'
19
+ import { captureMessage, assessCandidate } from '../capture-evidence.mjs'
20
+ import { listCaptureJobs, recoverCaptureJobs, abandonCaptureJob } from '../capture-jobs.mjs'
21
+ import { listCandidates, stageCandidate, candidateSnapshot } from '../candidates.mjs'
22
+ import {writeMemory, indexLine, storeDirFor, storeRevision, forgetMemory, adoptCandidate, withdrawManagedSource, searchStore} from '../index.mjs'
23
23
 
24
- const HERE = dirname(fileURLToPath(import.meta.url))
24
+ const HERE = dirname(dirname(fileURLToPath(import.meta.url)))
25
25
 
26
26
  function sandbox() {
27
27
  const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-mem-capture-')))
@@ -261,7 +261,7 @@ test('每 N 轮触发一次:候选经写入闸落库并留下审计', async ()
261
261
  { type: 'feedback', summary: '改稿必须附「改了什么、为什么」', detail: '来自用户纠正', scope: 'global', meaning:{kind:'claim'},basis: 'explicit', evidence: [{ messageId: 's1:event-2', quote: '改稿必须附「改了什么、为什么」' }] },
262
262
  { type: 'user', summary: '验收要量化到字数与标点', scope: 'global', meaning:{kind:'claim'},basis: 'explicit', evidence: [{ messageId: 's1:event-2', quote: '验收要量化到字数与标点' }] },
263
263
  ])
264
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
264
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
265
265
  const { ctx, handlers } = mount(box, { llm })
266
266
  prepareMountedFixture()
267
267
  apply(ctx, { autoCapture: { everyTurns: 3 } })
@@ -308,7 +308,7 @@ test('每 N 轮触发一次:候选经写入闸落库并留下审计', async ()
308
308
  test('没有 llm 时不触发自动捕获,仍保留显式工具来源监听', async () => {
309
309
  const box = sandbox()
310
310
  try {
311
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
311
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
312
312
  const { ctx, handlers } = mount(box, {})
313
313
  prepareMountedFixture()
314
314
  apply(ctx, {})
@@ -321,7 +321,7 @@ test('没有 llm 时不触发自动捕获,仍保留显式工具来源监听',
321
321
  test('配置 enabled:false 时关闭自动捕获', async () => {
322
322
  const box = sandbox()
323
323
  try {
324
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
324
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
325
325
  const { ctx, handlers } = mount(box, { llm: fakeLlm([]) })
326
326
  prepareMountedFixture()
327
327
  apply(ctx, { autoCapture: { enabled: false } })
@@ -334,7 +334,7 @@ test('配置 enabled:false 时关闭自动捕获', async () => {
334
334
  test('模型输出坏 JSON 时只记审计,不写入、不抛错', async () => {
335
335
  const box = sandbox()
336
336
  try {
337
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
337
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
338
338
  const { ctx, handlers } = mount(box, { llm: fakeLlm('这不是 JSON') })
339
339
  prepareMountedFixture()
340
340
  apply(ctx, { autoCapture: { everyTurns: 1 } })
@@ -358,7 +358,7 @@ test('重复候选不会写第二次(走既有幂等闸)', async () => {
358
358
  const message = promptMessages(request).find((m) => m.role === 'user')
359
359
  return [{ type: 'user', summary: '回答要结论先行', scope: 'global', meaning:{kind:'claim'},basis: 'explicit', evidence: [{ messageId: message.id, quote: '回答一直都要结论先行' }] }]
360
360
  })
361
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
361
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
362
362
  const { ctx, handlers } = mount(box, { llm })
363
363
  prepareMountedFixture()
364
364
  apply(ctx, { autoCapture: { everyTurns: 1 } })
@@ -440,7 +440,7 @@ test('信念:推断只成为候选,同一消息重复引用不增加独立
440
440
  })
441
441
 
442
442
  async function runCapture(box, payload, cwd = box.cwd, sessionId = 'evidence-session') {
443
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
443
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
444
444
  const { ctx, handlers } = mount(box, { llm: fakeLlm(payload) })
445
445
  ctx.workingDirectory.get = () => cwd
446
446
  prepareMountedFixture()
@@ -551,7 +551,7 @@ test('接线:模型推断持久化但不进入长期索引或注入上下文',
551
551
  assert.doesNotMatch(readFileSync(join(project, '.audit.jsonl'), 'utf8'), /暖色/)
552
552
  writeFixtureMemory({ type: 'user', dir: box.store, line: indexLine('user', '已确认的有效偏好使用中文回答'), scope: 'global' })
553
553
  const { ctx, captured } = mount(box)
554
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
554
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
555
555
  prepareMountedFixture()
556
556
  apply(ctx, { autoCapture: { enabled: false } })
557
557
  const injected = captured.context.text({ agent: { session: {} } })
@@ -660,7 +660,7 @@ test('能力失败仅保存固定错误码,同一快照重开不把失败当
660
660
  let prepares = 0
661
661
  const llm = fakeLlm([])
662
662
  llm.prepareCall = async () => { prepares++; throw Object.assign(new Error('synthetic private capability diagnostic'), { code: 'INVALID_MODEL_REASONING' }) }
663
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
663
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
664
664
  const run = async () => {
665
665
  const { ctx, handlers } = mount(box, { llm })
666
666
  prepareMountedFixture()
@@ -691,7 +691,7 @@ test('显式捕获路由优先于主会话模型,预算不被主请求头覆
691
691
  return prepare(config, ...rest)
692
692
  }
693
693
  const { ctx, handlers } = mount(box, { llm })
694
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
694
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
695
695
  prepareMountedFixture()
696
696
  apply(ctx, { autoCapture: { everyTurns: 1, provider: 'configured-capture', model: 'configured-model', maxTokens: 777 } })
697
697
  const emit = ((session,event)=>{for(const [name,handler] of handlers)if(name==='session/event')handler(session,event)}), session = { id: 'explicit-route-session' }
@@ -706,7 +706,7 @@ test('显式捕获路由优先于主会话模型,预算不被主请求头覆
706
706
  test('不完整或空白显式路由在注册能力前明确拒绝配置', async () => {
707
707
  const box = sandbox()
708
708
  try {
709
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs')
709
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs')
710
710
  for (const route of [{ provider: 'one' }, { model: 'one' }, { provider: ' ', model: 'one' }, { provider: 'one', model: 3 }]) {
711
711
  const llm = fakeLlm([]), { ctx, captured, handlers } = mount(box, { llm })
712
712
  assert.throws(() => apply(ctx, { autoCapture: { ...route } }), /memory-invalid-config/)
@@ -754,7 +754,7 @@ test('作业接线:损坏账本暂停自动捕获,六工具与主注入保
754
754
  mkdirSync(box.store, { recursive: true }); writeFileSync(join(box.store, '.capture-jobs.json'), '{bad')
755
755
  const llm = fakeLlm([])
756
756
  const { ctx, handlers, captured } = mount(box, { llm })
757
- const { applyWithValidationFixture: apply } = await import('./fixture-host.mjs'); prepareMountedFixture(); apply(ctx)
757
+ const { applyWithValidationFixture: apply } = await import('../fixture-host.mjs'); prepareMountedFixture(); apply(ctx)
758
758
  assert.equal(handlers.filter(([name]) => name === 'session/event').length, 1); assert.equal(llm.calls, 0)
759
759
  assert.equal(captured.tools.length, 6); assert.equal(typeof captured.context.text, 'function')
760
760
  const marker = JSON.parse(readFileSync(join(box.store, '.loaded.json')))
@@ -1,9 +1,9 @@
1
- import {writeFixtureMemory} from './fixture-host.mjs'
1
+ import {writeFixtureMemory} from '../fixture-host.mjs'
2
2
  import {realpathSync} from 'node:fs'
3
- import {applyWithOwnerFixture as apply} from './fixture-host.mjs'
3
+ import {applyWithOwnerFixture as apply} from '../fixture-host.mjs'
4
4
  /**
5
5
  * 记忆系统 × 宿主对话压缩(compaction)的兼容性测试。
6
- * 跑法:node --test ~/.dsh/plugins/memory/test-compaction-compat.mjs
6
+ * 跑法:node --test ~/.dsh/plugins/memory/test/test-compaction-compat.mjs
7
7
  *
8
8
  * 背景:DSH 自带对话压缩(dsh-compaction-basic + /compact + 工具输出修剪),
9
9
  * 会在 token 压力上升时把较早历史换成摘要。记忆系统必须和它互不干扰。
@@ -30,8 +30,8 @@ import {
30
30
  statusOf,
31
31
  storeDirFor,
32
32
  writeMemory,
33
- } from './index.mjs'
34
- import { entryId } from './security.mjs'
33
+ } from '../index.mjs'
34
+ import { entryId } from '../security.mjs'
35
35
 
36
36
  /** 宿主 tool-result-pruner 的默认阈值,见 dsh-base/cordis.patch.yml。 */
37
37
  const HOST_TOOL_RESULT_LIMIT = 8192
@@ -1,7 +1,7 @@
1
1
  import {realpathSync} from 'node:fs'
2
- import {applyWithOwnerFixture as apply} from './fixture-host.mjs'
2
+ import {applyWithOwnerFixture as apply} from '../fixture-host.mjs'
3
3
  /**
4
- * 宿主 API 兼容与降级。跑法:node --test ~/.dsh/plugins/memory/test-compat.mjs
4
+ * 宿主 API 兼容与降级。跑法:node --test ~/.dsh/plugins/memory/test/test-compat.mjs
5
5
  *
6
6
  * 目的:宿主升级后能区分「插件坏了」和「宿主变了」。
7
7
  * 没有 systemPrompt.context 就没法注入 → 立刻抛错(响);
@@ -14,7 +14,7 @@ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import { tmpdir } from 'node:os'
16
16
 
17
- import { selfCheck, storeDirFor } from './index.mjs'
17
+ import { selfCheck, storeDirFor } from '../index.mjs'
18
18
 
19
19
  function sandbox() {
20
20
  const root = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-mem-compat-')))
@@ -1,6 +1,6 @@
1
1
  import {realpathSync} from 'node:fs'
2
2
  /**
3
- * 并发与故障注入。跑法:node --test ~/.dsh/plugins/memory/test-concurrency.mjs
3
+ * 并发与故障注入。跑法:node --test ~/.dsh/plugins/memory/test/test-concurrency.mjs
4
4
  *
5
5
  * 这两类最容易被本地测试放过:
6
6
  * · 进程内锁测过了,跨进程仍可能丢更新
@@ -24,9 +24,9 @@ import { join, dirname } from 'node:path'
24
24
  import { fileURLToPath } from 'node:url'
25
25
  import { tmpdir } from 'node:os'
26
26
 
27
- import { apply, storeDirFor, writeMemory } from './index.mjs'
27
+ import { apply, storeDirFor, writeMemory } from '../index.mjs'
28
28
 
29
- const HERE = dirname(fileURLToPath(import.meta.url))
29
+ const HERE = dirname(dirname(fileURLToPath(import.meta.url)))
30
30
  const WORKER = join(HERE, 'worker-write.mjs')
31
31
 
32
32
  function sandbox() {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 冲突检测(同一事实、不同取值)。跑法:node --test ~/.dsh/plugins/memory/test-conflict.mjs
2
+ * 冲突检测(同一事实、不同取值)。跑法:node --test ~/.dsh/plugins/memory/test/test-conflict.mjs
3
3
  *
4
4
  * 规则只有一条:**非取值部分完全相同,且恰好一个取值不同** → 疑似冲突。
5
5
  * 它产生「警告」而不是「拒绝」,因为文本结构无法区分「改口」和「枚举」:
@@ -14,7 +14,7 @@ import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
14
14
  import { join } from 'node:path'
15
15
  import { tmpdir } from 'node:os'
16
16
 
17
- import { conflictSignature, findConflict, storeDirFor, writeMemory } from './index.mjs'
17
+ import { conflictSignature, findConflict, storeDirFor, writeMemory } from '../index.mjs'
18
18
 
19
19
  function sandbox() {
20
20
  const root = mkdtempSync(join(tmpdir(), 'dsh-mem-conflict-'))
@@ -1,6 +1,6 @@
1
1
  import {test} from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
- import {memoryContextProjection,reconcileMemoryContext,assertCurrentMemoryRequest,setupMemoryHistory,HISTORY_PROJECTION_KEY} from './context-history.mjs'
3
+ import {memoryContextProjection,reconcileMemoryContext,assertCurrentMemoryRequest,setupMemoryHistory,HISTORY_PROJECTION_KEY} from '../context-history.mjs'
4
4
  const header='Current runtime context. This snapshot supersedes earlier runtime-context snapshots.'
5
5
  const context=(id,text,extra=[])=>{const sections=[{name:'memory:index',text},...extra];return{id,role:'user',source:{kind:'runtime-context',form:'snapshot',sections},content:[{type:'text',text:header+'\n\n'+sections.map(s=>s.text).join('\n\n')}]}}
6
6
  const freeze=value=>{if(value&&typeof value==='object'){Object.values(value).forEach(freeze);Object.freeze(value)}return value}
@@ -1,7 +1,7 @@
1
1
  import test from 'node:test'
2
2
  import assert from 'node:assert/strict'
3
3
  import {DatabaseSync} from 'node:sqlite'
4
- import {CONTROL_APPLICATION_ID,CONTROL_SCHEMA_SQL,assertControlSchema,readControlStateRows} from './control-schema.mjs'
4
+ import {CONTROL_APPLICATION_ID,CONTROL_SCHEMA_SQL,assertControlSchema,readControlStateRows} from '../control-schema.mjs'
5
5
  const setup=db=>db.exec(CONTROL_SCHEMA_SQL+'; PRAGMA application_id='+CONTROL_APPLICATION_ID+'; PRAGMA user_version=1;')
6
6
  const box=run=>{const db=new DatabaseSync(':memory:');try{setup(db);run(db)}finally{db.close()}}
7
7
  test('one live connection reads newly added and removed schema rows rather than a cached approval',()=>box(db=>{
@@ -0,0 +1,125 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
4
+ import { join } from 'node:path'
5
+ import { tmpdir } from 'node:os'
6
+
7
+ import { detectRoot, detectStore } from '../detect.mjs'
8
+
9
+ function store(files = {}) {
10
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-detect-'))
11
+ for (const [name, text] of Object.entries(files)) writeFileSync(join(dir, name), text)
12
+ return dir
13
+ }
14
+
15
+ function ids(report) {
16
+ return report.findings.map((finding) => finding.id)
17
+ }
18
+
19
+ test('检测:干净库不报错,锚点用折叠比较(标题带标点也能解析)', () => {
20
+ const dir = store({
21
+ 'MEMORY.md': '# 记忆索引\n\n## user\n\n- [user] 中文母语 — user.md#身份说明\n',
22
+ 'user.md': '## 身份 说明\n\n中文母语写作者。\n',
23
+ })
24
+ try {
25
+ const report = detectStore(dir)
26
+ assert.equal(report.summary.errors, 0)
27
+ assert.ok(!ids(report).includes('I2-missing-anchor'))
28
+ assert.equal(report.metrics.indexLines, 1)
29
+ } finally {
30
+ rmSync(dir, { recursive: true, force: true })
31
+ }
32
+ })
33
+
34
+ test('检测:坏索引行、缺失主题文件、重复行、冲突签名都会被报出来', () => {
35
+ const dir = store({
36
+ 'MEMORY.md': [
37
+ '# 记忆索引',
38
+ '',
39
+ '## project',
40
+ '',
41
+ '- [nope] 类型不合法 — user.md',
42
+ '- [project] 剧本上限 100 集 — missing.md',
43
+ '- [project] 剧本上限 120 集 — project.md',
44
+ '- [project] 剧本上限 120 集 — project.md',
45
+ '',
46
+ ].join('\n'),
47
+ 'project.md': '## 正文\n\n剧本上限 120 集。\n',
48
+ })
49
+ try {
50
+ const report = detectStore(dir, { scope: 'project' })
51
+ const seen = ids(report)
52
+ assert.ok(seen.includes('I1-invalid-index-line'))
53
+ assert.ok(seen.includes('I2-missing-topic-file'))
54
+ assert.ok(seen.includes('I6-duplicate-line'))
55
+ assert.ok(seen.includes('I7-conflict'))
56
+ assert.equal(report.summary.errors >= 2, true)
57
+ } finally {
58
+ rmSync(dir, { recursive: true, force: true })
59
+ }
60
+ })
61
+
62
+ test('检测:预算超限与过期标记语法错误', () => {
63
+ const lines = ['# 记忆索引', '', '## user', '']
64
+ for (let i = 0; i < 302; i += 1) lines.push(`- [user] 条目 ${i} — user.md`)
65
+ const dir = store({ 'MEMORY.md': lines.join('\n'), 'user.md': '# 用户\n' })
66
+ try {
67
+ const report = detectStore(dir)
68
+ const seen = ids(report)
69
+ assert.ok(seen.includes('I8-budget-lines'))
70
+ assert.equal(report.metrics.indexLines, 302)
71
+ } finally {
72
+ rmSync(dir, { recursive: true, force: true })
73
+ }
74
+ })
75
+
76
+ test('检测:根目录会报告空库与内容完全相同的重复库', () => {
77
+ const root = mkdtempSync(join(tmpdir(), 'dsh-detect-root-'))
78
+ const projectA = join(root, 'projects', 'v2-a')
79
+ const projectB = join(root, 'projects', 'v2-b')
80
+ const empty = join(root, 'projects', 'v2-empty')
81
+ mkdirSync(projectA, { recursive: true })
82
+ mkdirSync(projectB, { recursive: true })
83
+ mkdirSync(empty, { recursive: true })
84
+ const body = '# 记忆索引\n\n## project\n\n- [project] 同一条 — project.md\n'
85
+ writeFileSync(join(root, 'MEMORY.md'), body)
86
+ writeFileSync(join(projectA, 'MEMORY.md'), body)
87
+ writeFileSync(join(projectB, 'MEMORY.md'), body)
88
+ try {
89
+ const report = detectRoot(root)
90
+ const seen = report.crossFindings.map((finding) => finding.id)
91
+ assert.ok(seen.includes('X1-empty-project-store'))
92
+ assert.ok(seen.includes('X2-duplicate-store'))
93
+ assert.equal(report.stores.length, 4)
94
+ } finally {
95
+ rmSync(root, { recursive: true, force: true })
96
+ }
97
+ })
98
+
99
+ test('检测:正文块出现在标题之前会被提示(结构检查)', () => {
100
+ const dir = store({
101
+ 'MEMORY.md': '# 记忆索引\n\n## feedback\n\n- [feedback] 先读再改 — feedback.md\n',
102
+ 'feedback.md': '[candidate:aaaaaaaa]\n先读再改\n',
103
+ })
104
+ try {
105
+ const report = detectStore(dir)
106
+ assert.ok(ids(report).includes('I14-candidate-without-heading'))
107
+ } finally {
108
+ rmSync(dir, { recursive: true, force: true })
109
+ }
110
+ })
111
+
112
+ test('检测:段落以未完成标点结尾会被标记为截断;列表引导句不误报', () => {
113
+ const dir = store({
114
+ 'MEMORY.md': '# 索引\n\n## user\n\n- [user] 一条 — user.md\n',
115
+ 'user.md': '# user\n\n## 记录\n\n主要产出:\n\n- 剧本\n- 剪辑\n\n- 2026-10-28|交接文件需自带上下文,\n',
116
+ })
117
+ try {
118
+ const report = detectStore(dir)
119
+ const truncated = report.findings.filter((finding) => finding.id === 'I14-truncated-paragraph')
120
+ assert.equal(truncated.length, 1)
121
+ assert.match(truncated[0].message, /交接文件/)
122
+ } finally {
123
+ rmSync(dir, { recursive: true, force: true })
124
+ }
125
+ })
@@ -0,0 +1,72 @@
1
+ import { test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createHash } from 'node:crypto'
4
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { dirname, join } from 'node:path'
6
+ import { tmpdir } from 'node:os'
7
+ import { fileURLToPath } from 'node:url'
8
+
9
+ const { planDream } = await import(join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'dream.mjs'))
10
+ const hash = (file) => createHash('sha256').update(readFileSync(file)).digest('hex')
11
+
12
+ function store() {
13
+ const root = mkdtempSync(join(tmpdir(), 'dsh-dream-'))
14
+ writeFileSync(join(root, 'MEMORY.md'), [
15
+ '# 索引',
16
+ '',
17
+ '## project',
18
+ '',
19
+ '- [project] 重复条目 — project.md',
20
+ '- [project] 重复条目 — project.md',
21
+ '- [project] 指向缺失文件 — missing.md',
22
+ '- [project] 当前版本 v0.2.0 — project.md',
23
+ '',
24
+ ].join('\n'))
25
+ writeFileSync(join(root, 'project.md'), '# project\n\n## 一\n\n正文。\n')
26
+ return root
27
+ }
28
+
29
+ test('做梦 P0:只生成 op 计划,不改任何文件、不调模型', () => {
30
+ const root = store()
31
+ try {
32
+ const before = hash(join(root, 'MEMORY.md'))
33
+ const plan = planDream(root)
34
+ assert.equal(plan.readOnly, true)
35
+ assert.equal(plan.applySupported, false)
36
+ assert.equal(plan.modelCalls, 0)
37
+ const kinds = plan.ops.map((op) => op.kind)
38
+ assert.ok(kinds.includes('merge-duplicate'))
39
+ assert.ok(kinds.includes('repair-reference'))
40
+ assert.ok(kinds.includes('compact-index') || kinds.includes('review-stale'))
41
+ assert.ok(plan.ops.every((op) => /^[0-9a-f]{12}$/.test(op.opId)))
42
+ assert.equal(hash(join(root, 'MEMORY.md')), before)
43
+ } finally {
44
+ rmSync(root, { recursive: true, force: true })
45
+ }
46
+ })
47
+
48
+ test('做梦 P1:只自动执行 compact-index,需 reviewed 哈希,可重复执行且不二次改动', async () => {
49
+ const root = mkdtempSync(join(tmpdir(), 'dsh-dream-apply-'))
50
+ try {
51
+ const lines = ['# 索引', '', '## project', '']
52
+ for (let index = 0; index < 302; index += 1) lines.push(`- [project] 条目 ${index} 编号 ${index} — project.md`)
53
+ writeFileSync(join(root, 'MEMORY.md'), lines.join('\n') + '\n')
54
+ writeFileSync(join(root, 'project.md'), '# project\n\n## 一\n\n正文。\n')
55
+ const { planDream, applyDream, planHash } = await import(join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'dream.mjs'))
56
+ const before = hash(join(root, 'MEMORY.md'))
57
+ const plan = planDream(root)
58
+ const compact = plan.ops.filter((op) => op.kind === 'compact-index')
59
+ assert.ok(compact.length >= 1, '超限库应产生 compact-index op')
60
+ assert.throws(() => applyDream(plan, 'deadbeef'), /dream-review-hash-mismatch/)
61
+ const result = applyDream(plan, planHash(plan))
62
+ assert.equal(result.applied >= 1, true)
63
+ assert.ok(result.backup !== null && Object.keys(result.backup).length >= 1)
64
+ assert.ok(result.verification.every((row) => row.ok))
65
+ assert.ok(readFileSync(join(root, 'MEMORY.md'), 'utf8') !== readFileSync(Object.values(result.backup)[0] + '/MEMORY.md', 'utf8') || hash(join(root, 'MEMORY.md')) !== before)
66
+ const again = planDream(root)
67
+ const compactAgain = again.ops.filter((op) => op.kind === 'compact-index')
68
+ assert.equal(compactAgain.length, 0, '应用后不应再产生 compact-index op')
69
+ } finally {
70
+ rmSync(root, { recursive: true, force: true })
71
+ }
72
+ })