@jjh32456/dsh-longlive 1.6.7 → 1.8.0

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 (289) hide show
  1. package/CHANGELOG.md +70 -1508
  2. package/LICENSE +1 -1
  3. package/QUICKSTART.md +34 -13
  4. package/README.en.md +18 -7
  5. package/README.md +34 -9
  6. package/SPEC.md +5 -5
  7. package/cordis.patch.yml +23 -9
  8. package/docs/ANSWER-GUARD.md +92 -0
  9. package/docs/ARCHITECTURE.md +4 -3
  10. package/docs/AUTO-RECALL-WIRING.md +1 -1
  11. package/docs/AUTO-RECALL.md +15 -9
  12. package/docs/CONFIG.md +168 -35
  13. package/docs/ERRORS.md +17 -5
  14. package/docs/FAQ.md +7 -7
  15. package/docs/FEATURES.md +96 -28
  16. package/docs/MIGRATION-1.6.0.md +2 -2
  17. package/docs/OPERATIONS.md +6 -6
  18. package/docs/PANEL-INTEGRATION.md +6 -0
  19. package/docs/PRIVACY.md +88 -0
  20. package/docs/QUICKSTART.md +5 -5
  21. package/docs/README.en.md +4 -2
  22. package/docs/RESIDENT-WATCHER.md +1 -1
  23. package/docs/SESSION-WATCH.md +6 -6
  24. package/docs/TROUBLESHOOTING.md +4 -4
  25. package/package.json +47 -117
  26. package/runbook.md +2 -2
  27. package/scripts/backfill-auto-cards.mjs +98 -0
  28. package/scripts/install-surgery-task.ps1 +5 -2
  29. package/scripts/lib/session-watch-fixture.mjs +1 -1
  30. package/scripts/longlive-doctor.mjs +6 -0
  31. package/scripts/migrate-180.mjs +163 -0
  32. package/scripts/recall-index-build.mjs +128 -0
  33. package/scripts/session-watch-drill.mjs +1 -1
  34. package/scripts/surgery-run.cmd +1 -1
  35. package/scripts/unattended-surgery.ps1 +5 -2
  36. package/scripts/verify-card-anchors.mjs +138 -0
  37. package/src/actions/audit.js +125 -0
  38. package/src/actions/evidence.js +122 -0
  39. package/src/actions/fs.js +162 -0
  40. package/src/activity/index.js +760 -0
  41. package/src/activity/model.js +351 -0
  42. package/src/activity/store.js +644 -0
  43. package/src/activity/thinking.js +176 -0
  44. package/src/adapters/constants.js +11 -0
  45. package/src/adapters/engram/data/aliases.json +51 -0
  46. package/src/adapters/engram/data/policy.json +8 -0
  47. package/src/adapters/engram/data/routing-rules.json +546 -0
  48. package/src/adapters/engram/index.js +235 -0
  49. package/src/adapters/registry.js +315 -0
  50. package/src/answer/audit.js +193 -0
  51. package/src/answer/evidence.js +30 -0
  52. package/src/answer/guard.js +1370 -0
  53. package/src/answer/index.js +1067 -0
  54. package/src/answer/query.js +111 -0
  55. package/src/answer/second-hop.mjs +101 -0
  56. package/src/auto-truncate.js +1 -1
  57. package/src/config.js +171 -47
  58. package/src/deps.js +6 -4
  59. package/src/features.js +182 -6
  60. package/src/heartbeat.js +156 -23
  61. package/src/identity/handover.js +52 -1
  62. package/src/identity/index.js +13 -5
  63. package/src/identity/inject-position.js +2 -2
  64. package/src/index.js +97 -7
  65. package/src/lifecycle/gap-clocks.js +328 -0
  66. package/src/lifecycle/index.js +269 -0
  67. package/src/live.js +22 -2
  68. package/src/memory/activity-capture.js +229 -0
  69. package/src/memory/anchors.js +194 -0
  70. package/src/memory/audit-records.js +222 -0
  71. package/src/memory/auto-cards.js +401 -0
  72. package/src/memory/capture.js +442 -0
  73. package/src/memory/card-anchors.js +203 -0
  74. package/src/memory/conflicts.js +106 -0
  75. package/src/memory/decay.js +311 -0
  76. package/src/memory/distill.js +471 -0
  77. package/src/memory/event-index.js +917 -0
  78. package/src/memory/evidence-chain.js +295 -0
  79. package/src/memory/evidence.js +145 -0
  80. package/src/memory/filter-runner.js +1 -1
  81. package/src/memory/filter.js +5 -1
  82. package/src/memory/forget-v2.js +410 -0
  83. package/src/memory/forget.js +71 -0
  84. package/src/memory/frames-activation.js +247 -0
  85. package/src/memory/fts-query.js +174 -0
  86. package/src/memory/governance.js +604 -0
  87. package/src/memory/index.js +1 -0
  88. package/src/memory/l2-gate.js +169 -0
  89. package/src/memory/lock.js +121 -0
  90. package/src/memory/migrate.js +139 -0
  91. package/src/memory/recall-v2.js +223 -0
  92. package/src/memory/recall.js +65 -2
  93. package/src/memory/records.js +425 -0
  94. package/src/memory/retention.js +128 -0
  95. package/src/memory/review.js +124 -0
  96. package/src/memory/trust.js +88 -0
  97. package/src/memory/version-chain.js +264 -0
  98. package/src/memory/wal.js +137 -0
  99. package/src/memory/working-frame.js +516 -0
  100. package/src/metrics/classify.mjs +5 -3
  101. package/src/metrics/index.mjs +21 -1
  102. package/src/metrics/prestep-latency.mjs +300 -0
  103. package/src/metrics/store.mjs +25 -0
  104. package/src/policy-profile.js +18 -3
  105. package/src/recall/anaphora.mjs +87 -0
  106. package/src/recall/cards.mjs +7 -0
  107. package/src/recall/embedding.mjs +8 -8
  108. package/src/recall/entities.mjs +112 -0
  109. package/src/recall/index.js +235 -19
  110. package/src/recall/inject-order.mjs +10 -8
  111. package/src/recall/privacy.mjs +105 -16
  112. package/src/recall/recall-core.mjs +116 -10
  113. package/src/recall/shared-visibility.js +302 -0
  114. package/src/recall/time-normalizer.mjs +196 -0
  115. package/src/role/activation.js +191 -0
  116. package/src/role/attention.js +318 -0
  117. package/src/role/base.js +36 -0
  118. package/src/role/channels.js +423 -0
  119. package/src/role/index.js +425 -0
  120. package/src/role/interfaces.js +115 -0
  121. package/src/role/personality.js +370 -0
  122. package/src/role/readers.js +151 -0
  123. package/src/role/relationships.js +477 -0
  124. package/src/role/store.js +202 -0
  125. package/src/role/writeprotect.js +373 -0
  126. package/src/router/aliases.json +176 -25
  127. package/src/router/background/agent-runner.mjs +1 -1
  128. package/src/router/background/anchor.mjs +1 -1
  129. package/src/router/background/audit.mjs +2 -2
  130. package/src/router/background/budget-ledger.mjs +2 -2
  131. package/src/router/background/host-sim.mjs +1 -1
  132. package/src/router/background/memory.mjs +4 -4
  133. package/src/router/background/queue.mjs +1 -1
  134. package/src/router/background/retry.mjs +1 -1
  135. package/src/router/background/router.mjs +1 -1
  136. package/src/router/background/stream.mjs +1 -1
  137. package/src/router/background/tool-policy.mjs +1 -1
  138. package/src/router/design-headings.json +5 -5
  139. package/src/router/face/config.mjs +1 -1
  140. package/src/router/face/identity.mjs +3 -3
  141. package/src/router/judge.mjs +8 -7
  142. package/src/router/policies/generic.json +21 -4
  143. package/src/router/route.js +109 -22
  144. package/src/router/router.mjs +10 -6
  145. package/src/router/routing-rules.json +260 -445
  146. package/src/router/run-sim.mjs +8 -8
  147. package/src/router/runtime.js +23 -12
  148. package/src/router/service/board.mjs +95 -15
  149. package/src/router/service/config.mjs +5 -5
  150. package/src/router/service/context.mjs +1 -1
  151. package/src/router/service/hint.mjs +14 -0
  152. package/src/router/service/queue.mjs +1 -1
  153. package/src/router/service/service.mjs +27 -11
  154. package/src/router/shadow.mjs +4 -3
  155. package/src/surgery-recovery.js +2 -2
  156. package/src/tool-output.js +2 -2
  157. package/src/tools.js +39 -7
  158. package/src/truncate-visible.js +2 -2
  159. package/src/ui/actions.js +1312 -0
  160. package/src/ui/panel-api.js +367 -0
  161. package/src/ui/panel-auth.js +251 -0
  162. package/src/ui/panel-integrity.js +243 -0
  163. package/src/ui/routes-readonly.js +695 -0
  164. package/src/ui/routes.js +3 -0
  165. package/docs/DEV.md +0 -279
  166. package/docs/PRESENCE-BUS-WIRING.md +0 -491
  167. package/docs/PRESENCE-BUS.md +0 -694
  168. package/docs/SURGERY.md +0 -186
  169. package/scripts/mark-core-cards.mjs +0 -289
  170. package/scripts/pack.mjs +0 -392
  171. package/scripts/router-proposals.mjs +0 -386
  172. package/src/router/fixtures/M1.json +0 -16
  173. package/src/router/fixtures/M2.json +0 -16
  174. package/src/router/fixtures/M3.json +0 -20
  175. package/src/router/fixtures/M4.json +0 -20
  176. package/src/router/fixtures/M5.json +0 -45
  177. package/src/router/fixtures/M6.json +0 -53
  178. package/src/router/fixtures/M7.json +0 -46
  179. package/src/router/fixtures/S1.json +0 -20
  180. package/src/router/fixtures/S2.json +0 -23
  181. package/src/router/fixtures/S3.json +0 -20
  182. package/src/router/fixtures/S4.json +0 -34
  183. package/src/router/fixtures/S5.json +0 -43
  184. package/src/router/fixtures/S6.json +0 -47
  185. package/src/router/fixtures/T1.json +0 -23
  186. package/src/router/fixtures/T2.json +0 -16
  187. package/src/router/fixtures/T3.json +0 -19
  188. package/src/router/fixtures/T4.json +0 -16
  189. package/src/router/fixtures/X1.json +0 -17
  190. package/src/router/fixtures/X2.json +0 -20
  191. package/src/router/gen-design-headings.mjs +0 -107
  192. package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
  193. package/test/auto-recall.test.mjs +0 -474
  194. package/test/auto-truncate.test.mjs +0 -931
  195. package/test/autobind.test.mjs +0 -385
  196. package/test/bus.test.mjs +0 -1572
  197. package/test/cli-guard.test.mjs +0 -198
  198. package/test/conclusion.test.mjs +0 -408
  199. package/test/config-schema.test.mjs +0 -204
  200. package/test/d6-output.test.mjs +0 -194
  201. package/test/d6.test.mjs +0 -395
  202. package/test/deps.test.mjs +0 -342
  203. package/test/doctor-setup.test.mjs +0 -297
  204. package/test/embedding-recall.test.mjs +0 -347
  205. package/test/feature-flags.test.mjs +0 -568
  206. package/test/filter-shadow.test.mjs +0 -170
  207. package/test/fixtures/baseline-0.8.0-registration.json +0 -556
  208. package/test/fixtures/d6-header-tools.json +0 -1
  209. package/test/fixtures/lock-child.mjs +0 -67
  210. package/test/fixtures/pre-step-container.mjs +0 -100
  211. package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
  212. package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
  213. package/test/fixtures/profile-session-longlive-config.json +0 -20
  214. package/test/fixtures/proposal-cas-child.mjs +0 -66
  215. package/test/host-engine-status.test.mjs +0 -307
  216. package/test/hot-truncate.test.mjs +0 -1349
  217. package/test/identity-card-marker.test.mjs +0 -307
  218. package/test/identity-router/run.mjs +0 -721
  219. package/test/integration-pre-step.test.mjs +0 -553
  220. package/test/live.test.mjs +0 -1020
  221. package/test/maintenance-schedule.test.mjs +0 -538
  222. package/test/memory.test.mjs +0 -2828
  223. package/test/metrics.test.mjs +0 -478
  224. package/test/orchestrator.test.mjs +0 -266
  225. package/test/p1_probe.mjs +0 -97
  226. package/test/packaging.test.mjs +0 -182
  227. package/test/portability.test.mjs +0 -343
  228. package/test/rc4-regressions.test.mjs +0 -562
  229. package/test/rc5-regressions.test.mjs +0 -197
  230. package/test/rc6-chinese.test.mjs +0 -394
  231. package/test/router-1100/apply-off.test.mjs +0 -175
  232. package/test/router-1100/budget-reconcile.test.mjs +0 -215
  233. package/test/router-1100/config-context.test.mjs +0 -211
  234. package/test/router-1100/helpers.mjs +0 -129
  235. package/test/router-1100/hint-envelope.test.mjs +0 -150
  236. package/test/router-1100/lock-crossproc.test.mjs +0 -206
  237. package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
  238. package/test/router-1100/router-apply.test.mjs +0 -372
  239. package/test/router-1100/router-fixb.test.mjs +0 -602
  240. package/test/router-1100/router-proposals.test.mjs +0 -405
  241. package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
  242. package/test/router-1100/router-security-cr.test.mjs +0 -333
  243. package/test/router-1100/service-lifecycle.test.mjs +0 -220
  244. package/test/router-1100/tools-contract.test.mjs +0 -164
  245. package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
  246. package/test/router-1100/v6-contract.test.mjs +0 -158
  247. package/test/router-1100/v6b-contract.test.mjs +0 -293
  248. package/test/router-1100/v6c-contract.test.mjs +0 -249
  249. package/test/router-1100/wal-rotation.test.mjs +0 -375
  250. package/test/router-1100/wp1-config.test.mjs +0 -170
  251. package/test/router-1100/wp1-default-off.test.mjs +0 -77
  252. package/test/router-1100/wp1-model-status.test.mjs +0 -90
  253. package/test/router-1100/wp1-status.test.mjs +0 -220
  254. package/test/router-1100/wp1-tools.test.mjs +0 -146
  255. package/test/router-1100/wp5-cli.test.mjs +0 -240
  256. package/test/router-1100/wp5-model-auto.test.mjs +0 -418
  257. package/test/router-1100/wp6-merge.test.mjs +0 -386
  258. package/test/router-1100/wp6-wake.test.mjs +0 -502
  259. package/test/router-background/audit.test.mjs +0 -37
  260. package/test/router-background/budget-ledger.test.mjs +0 -330
  261. package/test/router-background/cross-process.test.mjs +0 -222
  262. package/test/router-background/fixtures/cross-child.mjs +0 -85
  263. package/test/router-background/fixtures/real-shapes.mjs +0 -239
  264. package/test/router-background/router.test.mjs +0 -1044
  265. package/test/router-background/stream-retry.test.mjs +0 -358
  266. package/test/router-background/tool-agent.test.mjs +0 -384
  267. package/test/router-face/bridge.test.mjs +0 -169
  268. package/test/router-face/face.test.mjs +0 -316
  269. package/test/router-failclosed.test.mjs +0 -466
  270. package/test/router-identity.test.mjs +0 -384
  271. package/test/router-l1-l2.contract.test.mjs +0 -1075
  272. package/test/router-memory-tools.test.mjs +0 -262
  273. package/test/router-policy.test.mjs +0 -225
  274. package/test/router-runtime.test.mjs +0 -438
  275. package/test/router-shadow.test.mjs +0 -287
  276. package/test/router.test.mjs +0 -903
  277. package/test/run-rc.mjs +0 -299
  278. package/test/run.mjs +0 -2469
  279. package/test/safety-defaults.test.mjs +0 -179
  280. package/test/seeded-load.test.mjs +0 -436
  281. package/test/session-original.test.mjs +0 -338
  282. package/test/surgery-recovery.test.mjs +0 -936
  283. package/test/t7_drive.mjs +0 -151
  284. package/test/tool-output.test.mjs +0 -393
  285. package/test/tool-schema.test.mjs +0 -177
  286. package/test/tools_contract.test.mjs +0 -716
  287. package/test/truncate-visible.test.mjs +0 -1150
  288. package/test/ui-flags.test.mjs +0 -477
  289. package/test/v4-hardening.test.mjs +0 -427
@@ -1,2828 +0,0 @@
1
- // Memory-layer acceptance tests (0.4.0).
2
- //
3
- // Wired into test/run.mjs after P6. Every fixture is created under the suite's
4
- // TMP directory, so the P6 "package tree is unchanged" check stays honest.
5
- // The cases map one-to-one to the acceptance list in the 0.4 research SPEC:
6
- // M1 index: dry-run / apply / completeness / idempotence / hand-written text
7
- // M2 consolidate: report only, cards and state untouched
8
- // M3 reinforce: +1, cap 9, backup, body byte-identical
9
- // M4 forget: dry-run writes nothing, confirm gate, tombstone before move
10
- // M5 out-of-bounds paths rejected with E_PATH_OUTSIDE_ROOT
11
- // M6 unset memoryRoot is an explicit error, never a guessed default
12
- // M7 frontmatter round-trip, empty values, multiline limit, malformed input
13
- // M8 state merge/backup, atomic rename/read-back rollback, corrupt JSON
14
- // M9 consolidate salience/target rules, excerpt, since and undated handling
15
- // M10 reinforce decay recovery across 0/1/many uses, strength cap and body preservation
16
- // M11 forget 90/365 boundaries, immune core, tombstone-before-move, move failure
17
- // M12 tool layer schemas and coded errors for invalid arguments
18
- // M13 BOM+CRLF cards survive update/reinforce, and a throwing read-back restores the backup
19
- // M14 a minimal card gains missing fields with a real EOL before the closing fence (LF/CRLF)
20
- // M15 salience 0..9, empty numerics default (3/3/0), junk/out-of-range are coded errors
21
- // M16 memory_* tool entries reject null/non-plain args and config with E_BAD_ARGUMENT
22
- // M17 multiline frontmatter values are refused on write and reported on read
23
- // M18 memory paths refuse junction/symlink escapes by real path
24
- // M19 same-millisecond consolidation report backups never overwrite each other
25
- // M20 affect markers: marked > heuristic > default, bad markers reported, never thrown
26
- // M21 proposal ids: repeat run yields 0 new ids; lastConsolidated only advances when all resolved
27
- // M22 forget rules: strict 89/90/91 and 364/365/366 boundaries, salience-5 immunity, dry-run zero writes
28
- // M23 forget confirm: scaffold created only on confirm, per-card backups, three-question epitaphs, one audit line
29
- // M24 procedures: save/get/list, frontmatter contract, same-slug merge (version +1, old bytes in backups)
30
- // M25 procedures: trigger keyword filter, report +1/-1 with cap 9 / floor 0, revisions untouched
31
- // M26 index: procedures/ scanned and counted, 「程序性」 table, rows == cards, idempotent apply
32
- // M27 procedures: absent/empty directory and broken/incomplete/legacy cards never throw and stay counted
33
- // M28 recall: mixed scoring, deterministic order, topK, room/status filters, locked cards, read-only
34
- // M29 recall: empty query is E_RECALL_EMPTY_QUERY; no match is E_RECALL_NO_MATCH with an empty list
35
- // M30 forget: a locked card is immune, stays byte-identical, and is reported as immune
36
- // M31 consolidate: a locked card is neither a merge target nor a merge source
37
- // M32 status/salience: default active, locked/archived accepted, illegal values are coded errors
38
- // M33 index: active/locked/archived counted separately in the result and the INDEX.md counter
39
- // M40 B3 provenance: source_session/source_turn/written_at on new cards, kept on merge
40
- // M41 B3 conflict: same-slug/same-title/same-key-opposite detected, unrelated cards ignored, old card not overwritten
41
- // M42 B3 manifest: sha256 baseline, changed/missing/untracked/corrupt/missing manifest reported, verify is read-only
42
-
43
- import fs from 'node:fs';
44
- import path from 'node:path';
45
- import crypto from 'node:crypto';
46
-
47
- import {
48
- memoryIndex, extractGeneratedSection, countGeneratedRows, BEGIN_GENERATED, END_GENERATED,
49
- } from '../src/memory/index.js';
50
- import { memoryConsolidate, contentTokens, paragraphUnits, pickQuote } from '../src/memory/consolidate.js';
51
- import { memoryReinforce } from '../src/memory/reinforce.js';
52
- import { memoryForget } from '../src/memory/forget.js';
53
- import {
54
- parseFrontmatter, serializeFrontmatter, updateFrontmatter, appendLine,
55
- isRevLine, normalizedBody, countRevLines, daysBetween, DEFAULT_FIELDS,
56
- } from '../src/memory/frontmatter.js';
57
- import {
58
- readState, updateState, writeFileAtomic, appendAudit, backupFile,
59
- statePath, listMarkdownFiles,
60
- } from '../src/memory/state.js';
61
- import { resolveInside } from '../src/memory/paths.js';
62
- import { detectConflicts, writeCard } from '../src/memory/cards.js';
63
- import {
64
- manifestPath, readManifest, refreshManifest, verifyManifest,
65
- } from '../src/memory/manifest.js';
66
- import { TOOLS } from '../src/tools.js';
67
-
68
- const FIXED_DAY = '2026-11-02';
69
- const FIXED_NOW = new Date('2026-11-02T03:00:00Z');
70
-
71
- function sha(text) {
72
- return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
73
- }
74
-
75
- function fixture(base, name) {
76
- const dir = path.join(base, 'memory-cases', name);
77
- fs.mkdirSync(dir, { recursive: true });
78
- return dir;
79
- }
80
-
81
- function put(root, rel, text) {
82
- const abs = path.join(root, rel);
83
- fs.mkdirSync(path.dirname(abs), { recursive: true });
84
- fs.writeFileSync(abs, text, 'utf8');
85
- return abs;
86
- }
87
-
88
- /** Map of relative path -> sha256 for every file, optionally skipping prefixes. */
89
- function snapshot(root, skipPrefixes = []) {
90
- const map = new Map();
91
- const walk = (dir) => {
92
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
93
- const abs = path.join(dir, entry.name);
94
- const rel = path.relative(root, abs).split(path.sep).join('/');
95
- if (skipPrefixes.some((prefix) => rel.startsWith(prefix))) continue;
96
- if (entry.isDirectory()) walk(abs);
97
- else if (entry.isFile()) map.set(rel, sha(fs.readFileSync(abs, 'utf8')));
98
- }
99
- };
100
- walk(root);
101
- return map;
102
- }
103
-
104
- function sameSnapshot(assert, before, after, label) {
105
- assert(before.size === after.size, `${label}: file count ${before.size} -> ${after.size}`);
106
- for (const [rel, hash] of before) {
107
- assert(after.has(rel), `${label}: file disappeared: ${rel}`);
108
- assert(after.get(rel) === hash, `${label}: file changed: ${rel}`);
109
- }
110
- }
111
-
112
- function stripStamp(section) {
113
- return section
114
- .split('\n')
115
- .filter((line) => !line.startsWith('<!-- generated:'))
116
- .join('\n');
117
- }
118
-
119
- function backupNames(root) {
120
- const dir = path.join(root, '.longlive', 'backups');
121
- if (!fs.existsSync(dir)) return [];
122
- return fs.readdirSync(dir);
123
- }
124
-
125
- /** Temporarily replace node:fs functions (system-call fault injection only). */
126
- function withPatchedFs(patches, fn) {
127
- const saved = new Map();
128
- for (const [name, wrap] of Object.entries(patches)) {
129
- saved.set(name, fs[name]);
130
- fs[name] = wrap(fs[name].bind(fs));
131
- }
132
- try {
133
- return fn();
134
- } finally {
135
- for (const [name, original] of saved) fs[name] = original;
136
- }
137
- }
138
-
139
- /** Write a card with the fields the memory layer reads. */
140
- function cardFile({
141
- title, salience = 3, strength = 3, lastUsed = null, revisions = 0,
142
- date = null, tags = [], body = '正文。', status = null, scope = null,
143
- }) {
144
- return [
145
- '---',
146
- `title: ${title}`,
147
- ...(date ? [`date: ${date}`] : []),
148
- ...(tags.length ? [`tags: [${tags.join(', ')}]`] : []),
149
- ...(scope ? [`scope: ${scope}`] : []),
150
- ...(status ? [`status: ${status}`] : []),
151
- `salience: ${salience}`,
152
- `strength: ${strength}`,
153
- ...(lastUsed ? [`last_used: ${lastUsed}`] : []),
154
- `revisions: ${revisions}`,
155
- '---',
156
- `# ${title}`,
157
- '',
158
- body,
159
- '',
160
- ].join('\n');
161
- }
162
-
163
- function journalFile(day, lines) {
164
- return [
165
- '---',
166
- `title: ${day}`,
167
- `date: ${day}`,
168
- 'salience: 3',
169
- 'strength: 3',
170
- 'revisions: 0',
171
- '---',
172
- '',
173
- ...lines,
174
- '',
175
- ].join('\n');
176
- }
177
-
178
- /**
179
- * An oracle that is deliberately NOT src/memory/frontmatter.js.
180
- *
181
- * When the test parsed the mutated card with the same parser that produced it,
182
- * a bug in that parser made both sides agree and the assertion proved nothing.
183
- * This reader knows only what these tests need: `key: value` lines between the
184
- * first two fences, everything after the second fence is the body. It also
185
- * accepts a legacy card with no frontmatter at all.
186
- */
187
- function parseCardForAssertions(text) {
188
- const source = String(text).replace(/^\uFEFF/, '');
189
- const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(source);
190
- if (!match) return { fields: {}, body: source };
191
- const fields = {};
192
- for (const line of match[1].split(/\r?\n/)) {
193
- const at = line.indexOf(':');
194
- if (at <= 0) continue;
195
- fields[line.slice(0, at).trim()] = line.slice(at + 1).trim();
196
- }
197
- return { fields, body: match[2] };
198
- }
199
-
200
- function invokeTool(tool, args, config) {
201
- try {
202
- return { ok: true, value: tool.invoke(args, config) };
203
- } catch (error) {
204
- return { ok: false, error };
205
- }
206
- }
207
-
208
- function assertCodedError(assert, result, code, label) {
209
- assert(result.ok === false, `${label}: expected an error, got a successful call`);
210
- const error = result.error;
211
- assert(error instanceof Error, `${label}: thrown value is not an Error`);
212
- assert(error.code === code, `${label}: expected ${code}, got ${error.code ?? '(no code)'}: ${error.message}`);
213
- assert(/^E_[A-Z_]+$/.test(error.code), `${label}: code ${error.code} is not a stable E_* code`);
214
- assert(error.name !== 'TypeError' && error.name !== 'ReferenceError', `${label}: raw ${error.name} escaped`);
215
- assert(typeof error.message === 'string' && error.message.length > 0, `${label}: empty error message`);
216
- }
217
-
218
- function m1Index(runCase, assert, expectCode, TMP) {
219
- runCase('M1', 'memory_index: dry-run, completeness, idempotence, hand-written text', () => {
220
- const root = fixture(TMP, 'm1-index');
221
- put(root, 'topics/记忆解剖.md', [
222
- '---',
223
- 'title: 记忆解剖',
224
- 'date: 2026-11-02',
225
- 'tags: [memory, design]',
226
- 'salience: 5',
227
- 'strength: 4',
228
- 'last_used: 2026-10-30',
229
- 'revisions: 2',
230
- '---',
231
- '# 记忆解剖',
232
- '',
233
- 'salience 5 是核心记忆。',
234
- '',
235
- ].join('\n'));
236
- put(root, 'topics/工程约定.md', [
237
- '---',
238
- 'title: 工程约定',
239
- 'date: 2026-10-20',
240
- 'tags: [engineering]',
241
- 'salience: 3',
242
- 'strength: 3',
243
- 'last_used: 2026-10-01',
244
- 'revisions: 1',
245
- '---',
246
- '# 工程约定',
247
- '',
248
- '所有写入先备份。',
249
- '',
250
- ].join('\n'));
251
- put(root, 'journal/2026-11-01.md', [
252
- '---',
253
- 'title: 今天的日志',
254
- 'date: 2026-11-01',
255
- 'salience: 2',
256
- 'strength: 3',
257
- 'last_used: 2026-11-01',
258
- 'revisions: 0',
259
- '---',
260
- '# 今天的日志',
261
- '',
262
- '看了索引工具。',
263
- '',
264
- ].join('\n'));
265
- put(root, 'topics/legacy-one.md', '# 旧卡一\n\n没有 frontmatter 的旧卡。\n');
266
- put(root, 'journal/2026-10-31.md', '# 旧日志\n\n没有 frontmatter 的旧日志。\n');
267
- put(root, 'INDEX.md', [
268
- '# 记忆索引(手写)',
269
- '',
270
- '手写介绍,不许动。',
271
- '',
272
- BEGIN_GENERATED,
273
- '| 旧 | 旧 |',
274
- END_GENERATED,
275
- '',
276
- '底部手写:不要改。',
277
- '',
278
- ].join('\n'));
279
-
280
- const before = snapshot(root);
281
- const dry = memoryIndex({}, { memoryRoot: root, now: FIXED_NOW });
282
- assert(dry.dryRun === true && dry.applied === false, `dry-run flags: ${JSON.stringify({ dryRun: dry.dryRun, applied: dry.applied })}`);
283
- assert(dry.cards === 5, `expected 5 scanned cards, got ${dry.cards}`);
284
- assert(dry.legacy === 2, `expected 2 legacy cards, got ${dry.legacy}`);
285
- sameSnapshot(assert, before, snapshot(root), 'dry-run');
286
-
287
- const first = memoryIndex({ apply: true }, { memoryRoot: root, now: FIXED_NOW });
288
- assert(first.applied === true, 'first apply did not report applied');
289
- assert(first.cards === 5 && first.legacy === 2, `apply counts: cards=${first.cards} legacy=${first.legacy}`);
290
- const afterFirst = fs.readFileSync(path.join(root, 'INDEX.md'), 'utf8');
291
- assert(afterFirst.startsWith('# 记忆索引(手写)\n\n手写介绍,不许动。\n\n'), 'hand-written head was not preserved');
292
- assert(afterFirst.endsWith('\n\n底部手写:不要改。\n'), 'hand-written tail was not preserved');
293
- const section1 = extractGeneratedSection(afterFirst);
294
- assert(section1, 'generated section missing after apply');
295
- assert(countGeneratedRows(section1) === 5, `generated rows ${countGeneratedRows(section1)} != 5 cards`);
296
- for (const rel of ['topics/记忆解剖.md', 'topics/工程约定.md', 'journal/2026-11-01.md', 'topics/legacy-one.md', 'journal/2026-10-31.md']) {
297
- assert(section1.includes(rel), `generated section is missing card path ${rel}`);
298
- }
299
- assert(section1.includes('legacy: 2'), 'generated section does not count legacy cards');
300
- assert(backupNames(root).some((name) => name.startsWith('INDEX.') && name.endsWith('.md')), 'INDEX.md was not backed up');
301
-
302
- const later = new Date('2026-11-02T04:00:00Z');
303
- const second = memoryIndex({ apply: true }, { memoryRoot: root, now: later });
304
- assert(second.applied === true, 'second apply did not report applied');
305
- const afterSecond = fs.readFileSync(path.join(root, 'INDEX.md'), 'utf8');
306
- const section2 = extractGeneratedSection(afterSecond);
307
- assert(stripStamp(section1) === stripStamp(section2), 'generated section is not idempotent (only the timestamp may differ)');
308
- assert(afterSecond.startsWith('# 记忆索引(手写)\n\n手写介绍,不许动。\n\n'), 'hand-written head changed on second apply');
309
- assert(afterSecond.endsWith('\n\n底部手写:不要改。\n'), 'hand-written tail changed on second apply');
310
-
311
- const third = memoryIndex({ apply: true }, { memoryRoot: root, now: later });
312
- assert(third.changed === false, 'same-timestamp apply still reports changed:true');
313
-
314
- const state = JSON.parse(fs.readFileSync(path.join(root, '.longlive', 'memory-state.json'), 'utf8'));
315
- assert(state.indexBuiltAt === later.toISOString(), `indexBuiltAt ${state.indexBuiltAt} != ${later.toISOString()}`);
316
- const audit = fs.readFileSync(path.join(root, '.longlive', 'audit.log'), 'utf8').trim().split(/\r?\n/);
317
- assert(audit.length >= 2 && audit.every((line) => line.includes('memory_index')), 'audit.log should record each apply');
318
- return '5 cards (2 legacy), hand-written head/tail preserved, 2 applies byte-identical except timestamp';
319
- });
320
- }
321
-
322
- function m2Consolidate(runCase, assert, expectCode, TMP) {
323
- runCase('M2', 'memory_consolidate: proposals only, cards untouched, state records ids', () => {
324
- const root = fixture(TMP, 'm2-consolidate');
325
- put(root, 'topics/记忆解剖.md', [
326
- '---',
327
- 'title: 记忆解剖',
328
- 'tags: [memory, design]',
329
- 'salience: 5',
330
- 'strength: 4',
331
- 'last_used: 2026-10-30',
332
- 'revisions: 2',
333
- '---',
334
- '# 记忆解剖',
335
- '',
336
- '记忆解剖 讲 salience 与 strength 的关系。',
337
- '',
338
- ].join('\n'));
339
- put(root, 'topics/工程约定.md', [
340
- '---',
341
- 'title: 工程约定',
342
- 'tags: [engineering]',
343
- 'salience: 3',
344
- 'strength: 3',
345
- 'last_used: 2026-10-01',
346
- 'revisions: 1',
347
- '---',
348
- '# 工程约定',
349
- '',
350
- '工程约定:所有写入先备份。',
351
- '',
352
- ].join('\n'));
353
- put(root, 'journal/2026-10-01.md', [
354
- '---',
355
- 'date: 2026-10-01',
356
- '---',
357
- '# 旧日志',
358
- '',
359
- '这条在增量起点之前,不该被读到。',
360
- '',
361
- ].join('\n'));
362
- put(root, 'journal/2026-11-01.md', [
363
- '---',
364
- 'date: 2026-11-01',
365
- '---',
366
- '## 记录',
367
- '',
368
- '今天读了 记忆解剖,确认 salience 5 是核心记忆,决定保持。',
369
- '',
370
- '另一段:工程约定 里说所有写入先备份。',
371
- '',
372
- ].join('\n'));
373
- fs.mkdirSync(path.join(root, '.longlive'), { recursive: true });
374
- put(root, '.longlive/memory-state.json', `${JSON.stringify({
375
- lastConsolidated: '2026-10-15',
376
- indexBuiltAt: null,
377
- forgetRuns: [],
378
- }, null, 2)}\n`);
379
-
380
- const skip = ['.longlive/'];
381
- const before = snapshot(root, skip);
382
- const stateBefore = JSON.parse(fs.readFileSync(path.join(root, '.longlive', 'memory-state.json'), 'utf8'));
383
- const result = memoryConsolidate({}, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
384
- assert(result.readOnly === true && result.stateUpdated === false, 'consolidate must report readOnly/stateUpdated:false');
385
- assert(result.proposals.length === 2, `expected 2 proposals, got ${result.proposals.length}`);
386
- for (const proposal of result.proposals) {
387
- assert(proposal.excerpt.length <= 120, `excerpt too long: ${proposal.excerpt.length}`);
388
- assert(proposal.suggestedSalience >= 1 && proposal.suggestedSalience <= 5, 'suggestedSalience out of range');
389
- assert(proposal.source === 'journal/2026-11-01.md', `proposal read from wrong journal: ${proposal.source}`);
390
- assert(proposal.salienceSource === 'heuristic' || proposal.salienceSource === 'default', `unexpected salienceSource: ${proposal.salienceSource}`);
391
- }
392
- const targets = result.proposals.map((proposal) => proposal.target).sort();
393
- assert(targets.join(',') === 'topics/工程约定.md,topics/记忆解剖.md', `unexpected targets: ${targets.join(',')}`);
394
- assert(fs.existsSync(result.reportPath), 'consolidate report was not written');
395
- const report = fs.readFileSync(result.reportPath, 'utf8');
396
- assert(report.includes('# 巩固提案') && report.includes('摘录:') && report.includes('建议 salience:'), 'report is missing required sections');
397
- const stateAfter = JSON.parse(fs.readFileSync(path.join(root, '.longlive', 'memory-state.json'), 'utf8'));
398
- assert(stateAfter.lastConsolidated === stateBefore.lastConsolidated, 'pending proposals must not advance lastConsolidated');
399
- assert(stateAfter.seenProposals.length === 2 && stateAfter.seenProposals.every((entry) => entry.status === 'proposed'), 'new proposal ids were not recorded as proposed');
400
- sameSnapshot(assert, before, snapshot(root, skip), 'consolidate');
401
-
402
- const applied = expectCode(
403
- () => memoryConsolidate({ apply: true }, { memoryRoot: root, now: FIXED_NOW }),
404
- 'E_READ_ONLY',
405
- );
406
- assert(applied.message.includes('apply'), 'read-only error should mention apply');
407
- sameSnapshot(assert, before, snapshot(root, skip), 'consolidate apply:true');
408
- return '2 proposals from the incremental journal, report written, cards untouched, since pinned';
409
- });
410
- }
411
-
412
- function m3Reinforce(runCase, assert, TMP) {
413
- runCase('M3', 'memory_reinforce: +1, cap 9, backup, body byte-identical', () => {
414
- const root = fixture(TMP, 'm3-reinforce');
415
- const cardText = [
416
- '---',
417
- 'title: 加固测试',
418
- 'date: 2026-10-01',
419
- 'salience: 3',
420
- 'strength: 3',
421
- 'last_used: 2026-10-01',
422
- 'revisions: 1',
423
- '---',
424
- '# 加固测试',
425
- '',
426
- '正文第一段。',
427
- '',
428
- '正文第二段。',
429
- '',
430
- ].join('\n');
431
- put(root, 'topics/test.md', cardText);
432
- put(root, 'topics/cap.md', [
433
- '---',
434
- 'title: 封顶测试',
435
- 'salience: 4',
436
- 'strength: 9',
437
- 'last_used: 2026-09-01',
438
- 'revisions: 4',
439
- '---',
440
- '# 封顶测试',
441
- '',
442
- '已经到顶的卡。',
443
- '',
444
- ].join('\n'));
445
- put(root, 'topics/legacy.md', '# 旧卡\n\n旧正文。\n');
446
-
447
- const original = fs.readFileSync(path.join(root, 'topics/test.md'), 'utf8');
448
- const result = memoryReinforce(
449
- { path: 'topics/test.md', reason: '测试使用' },
450
- { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY },
451
- );
452
- assert(result.before.strength === 3 && result.after.strength === 4, `strength ${result.before.strength}->${result.after.strength}`);
453
- assert(result.before.revisions === 1 && result.after.revisions === 2, `revisions ${result.before.revisions}->${result.after.revisions}`);
454
- assert(result.after.last_used === FIXED_DAY, `last_used ${result.after.last_used}`);
455
- assert(result.capped === false, 'capped should be false below 9');
456
- assert(result.bodyIdentical === true, 'bodyIdentical should be true');
457
-
458
- const updated = fs.readFileSync(path.join(root, 'topics/test.md'), 'utf8');
459
- const parsed = parseCardForAssertions(updated);
460
- assert(parsed.fields.strength === '4' && parsed.fields.revisions === '2' && parsed.fields.last_used === FIXED_DAY, 'written frontmatter is wrong');
461
- assert(updated.includes('正文第一段。\n\n正文第二段。\n'), 'prose body changed');
462
- assert(normalizedBody(parsed.body) === normalizedBody(parseCardForAssertions(original).body), 'normalized body differs');
463
- assert(countRevLines(parsed.body) === 1, 'exactly one rev line should be appended');
464
- assert(updated.includes('<!-- rev: 2026-11-02 测试使用 -->'), 'rev line missing the reason');
465
- const backups = backupNames(root).filter((name) => name.startsWith('test.'));
466
- assert(backups.length === 1, `expected one backup of test.md, got ${backups.length}`);
467
- assert(fs.readFileSync(path.join(root, '.longlive', 'backups', backups[0]), 'utf8') === original, 'backup bytes differ from the original');
468
-
469
- const capped = memoryReinforce({ path: 'topics/cap.md' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
470
- assert(capped.after.strength === 9 && capped.capped === true, `cap failed: strength=${capped.after.strength} capped=${capped.capped}`);
471
-
472
- const legacyOriginal = '# 旧卡\n\n旧正文。\n';
473
- const legacy = memoryReinforce({ path: 'topics/legacy.md' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
474
- assert(legacy.before.legacy === true, 'legacy card was not recognized');
475
- const legacyUpdated = fs.readFileSync(path.join(root, 'topics/legacy.md'), 'utf8');
476
- assert(legacyUpdated.startsWith('---\n'), 'legacy card did not get a frontmatter block');
477
- assert(legacyUpdated.includes(legacyOriginal), 'legacy body was not preserved');
478
- assert(normalizedBody(parseCardForAssertions(legacyUpdated).body) === normalizedBody(legacyOriginal), 'legacy body changed after migration');
479
- return 'strength 3->4, revisions 1->2, backup verified, cap 9 held, legacy body intact';
480
- });
481
- }
482
-
483
- function m4Forget(runCase, assert, expectCode, TMP) {
484
- runCase('M4', 'memory_forget: dry-run zero writes, confirm gate, tombstones before move', () => {
485
- const root = fixture(TMP, 'm4-forget');
486
- const card = (title, salience, strength, lastUsed, body) => [
487
- '---',
488
- `title: ${title}`,
489
- `salience: ${salience}`,
490
- `strength: ${strength}`,
491
- ...(lastUsed ? [`last_used: ${lastUsed}`] : []),
492
- 'revisions: 1',
493
- '---',
494
- `# ${title}`,
495
- '',
496
- body,
497
- '',
498
- ].join('\n');
499
- put(root, 'topics/core.md', card('核心', 5, 1, '2020-01-01', '核心记忆不能被自动遗忘。'));
500
- put(root, 'topics/old.md', card('很久没用', 3, 1, '2024-01-01', '超过一年没用了。'));
501
- put(root, 'topics/stale.md', card('有点旧', 2, 1, '2026-05-01', '超过九十天且强度低。'));
502
- put(root, 'topics/fresh.md', card('还新鲜', 3, 5, '2026-10-30', '最近还在用。'));
503
- put(root, 'topics/undated.md', card('没日期', 3, 0, null, '没有 last_used。'));
504
-
505
- const before = snapshot(root);
506
- const dry = memoryForget({}, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
507
- assert(dry.dryRun === true && dry.applied === false, 'forget default must be a dry-run');
508
- assert(dry.proposals.length === 2, `expected 2 proposals, got ${dry.proposals.length}`);
509
- assert(dry.immune.length === 1 && dry.immune[0].path === 'topics/core.md', 'salience 5 card is not immune');
510
- assert(dry.undated.length === 1, `expected 1 undated card, got ${dry.undated.length}`);
511
- sameSnapshot(assert, before, snapshot(root), 'forget dry-run');
512
- assert(!fs.existsSync(path.join(root, 'tombstones.md')), 'dry-run created tombstones.md');
513
-
514
- expectCode(() => memoryForget({ confirm: 'yes' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY }), 'E_CONFIRM_REQUIRED');
515
- sameSnapshot(assert, before, snapshot(root), 'wrong confirm');
516
-
517
- const done = memoryForget({ confirm: 'FORGET' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
518
- assert(done.applied === true && done.moved === 2, `execute moved=${done.moved}`);
519
- assert(!fs.existsSync(path.join(root, 'topics/old.md')), 'tombstoned card still in topics/');
520
- assert(!fs.existsSync(path.join(root, 'topics/stale.md')), 'demoted card still in topics/');
521
- assert(fs.existsSync(path.join(root, 'archive/tombstones/topics/old.md')), 'tombstoned card not under archive/tombstones/');
522
- assert(fs.existsSync(path.join(root, 'epochs/topics/stale.md')), 'demoted card not under epochs/');
523
- assert(fs.existsSync(path.join(root, 'topics/core.md')), 'immune card was touched');
524
- assert(fs.existsSync(path.join(root, 'topics/fresh.md')), 'fresh card was touched');
525
- const tombs = fs.readFileSync(path.join(root, 'tombstones.md'), 'utf8');
526
- const tombLines = tombs.split(/\r?\n/).filter((line) => line.startsWith('- '));
527
- assert(tombLines.length === 2, `tombstones.md has ${tombLines.length} lines, expected 2`);
528
- assert(tombLines.every((line) => line.includes('recoverable:')), 'tombstone line is missing the recoverable path');
529
- assert(done.tombstoneLines === 2, `result tombstoneLines=${done.tombstoneLines}`);
530
- const auditLines = fs.readFileSync(path.join(root, '.longlive', 'audit.log'), 'utf8').trim().split(/\r?\n/);
531
- assert(auditLines.length === 1 && auditLines[0].includes('memory_forget'), 'audit.log should have exactly one memory_forget line');
532
- const state = JSON.parse(fs.readFileSync(path.join(root, '.longlive', 'memory-state.json'), 'utf8'));
533
- assert(state.forgetRuns.length === 1 && state.forgetRuns[0].moved === 2, 'forgetRuns was not recorded');
534
- return 'dry-run wrote nothing; FORGET moved 2 cards, 2 tombstone lines, immune card intact';
535
- });
536
- }
537
-
538
- function m5PathGuard(runCase, assert, expectCode, TMP) {
539
- runCase('M5', 'memory paths: six escapes rejected with E_PATH_OUTSIDE_ROOT', () => {
540
- const root = fixture(TMP, 'm5-paths');
541
- put(root, 'topics/seed.md', '---\ntitle: seed\n---\n# seed\n');
542
- const attempts = [
543
- '../../../etc/passwd',
544
- '..\\..\\..\\Windows\\win.ini',
545
- 'C:\\Windows\\win.ini',
546
- 'D:\\outside\\x.md',
547
- '/etc/passwd',
548
- 'topics/../../escape.md',
549
- ];
550
- for (const attempt of attempts) {
551
- const error = expectCode(
552
- () => memoryReinforce({ path: attempt }, { memoryRoot: root, today: FIXED_DAY }),
553
- 'E_PATH_OUTSIDE_ROOT',
554
- );
555
- assert(error.message.includes('E_PATH_OUTSIDE_ROOT'), `message for ${attempt} lacks the code marker`);
556
- }
557
- return `${attempts.length} escaping paths rejected before any read/write`;
558
- });
559
- }
560
-
561
- function m6NoRoot(runCase, assert, expectCode) {
562
- runCase('M6', 'unset memoryRoot errors; legacy cards parse with defaults', () => {
563
- for (const [tool, call] of [
564
- ['memory_index', () => memoryIndex({}, { strictMemoryRoot: true })],
565
- ['memory_consolidate', () => memoryConsolidate({}, { strictMemoryRoot: true })],
566
- ['memory_reinforce', () => memoryReinforce({ path: 'topics/x.md' }, { strictMemoryRoot: true })],
567
- ['memory_forget', () => memoryForget({}, { strictMemoryRoot: true })],
568
- ]) {
569
- const error = expectCode(call, 'E_MEMORY_ROOT_UNSET');
570
- assert(error.message.includes('memoryRoot'), `${tool} error should name memoryRoot`);
571
- }
572
-
573
- const legacy = parseFrontmatter('# 旧卡\n\n旧正文。\n');
574
- assert(legacy.legacy === true && legacy.hasFrontmatter === false, 'legacy card not marked legacy');
575
- assert(legacy.fields.salience === 3 && legacy.fields.strength === 3 && legacy.fields.revisions === 0, 'legacy defaults are wrong');
576
- assert(Array.isArray(legacy.fields.tags) && legacy.fields.tags.length === 0, 'legacy tags default is wrong');
577
- const migrated = updateFrontmatter('# 旧卡\n\n旧正文。\n', { strength: 4 });
578
- assert(migrated.startsWith('---\n') && migrated.includes('strength: 4'), 'migration did not add frontmatter');
579
- assert(migrated.endsWith('# 旧卡\n\n旧正文。\n'), 'migration changed the legacy body');
580
-
581
- expectCode(() => parseFrontmatter('---\ntitle: x\n'), 'E_FRONTMATTER_UNTERMINATED');
582
- return 'all four tools refuse an unset root; legacy defaults and migration verified';
583
- });
584
- }
585
-
586
- function m7Frontmatter(runCase, assert, expectCode) {
587
- runCase('M7', 'frontmatter: round-trip, Chinese/colon, empty values, multiline limit, invalid input', () => {
588
- const fields = {
589
- title: '中文:冒号: 仍在值里',
590
- date: '2026-11-02',
591
- tags: ['记忆', '设计', 'x:y'],
592
- salience: 4,
593
- strength: 7,
594
- last_used: '2026-10-31',
595
- revisions: 3,
596
- };
597
- const extras = { owner: '听澜', 'custom-key': '值:含冒号' };
598
- const text = serializeFrontmatter(fields, extras);
599
- const parsed = parseFrontmatter(text);
600
- assert(parsed.hasFrontmatter && !parsed.legacy, 'serialized block was not recognized as frontmatter');
601
- assert(parsed.fields.title === fields.title, `title round-trip: ${JSON.stringify(parsed.fields.title)}`);
602
- assert(parsed.fields.tags.join('|') === fields.tags.join('|'), `tags round-trip: ${parsed.fields.tags.join('|')}`);
603
- assert(parsed.fields.salience === 4 && parsed.fields.strength === 7 && parsed.fields.revisions === 3, 'numeric fields changed');
604
- assert(parsed.extras.owner === '听澜' && parsed.extras['custom-key'] === extras['custom-key'], 'extras changed');
605
- assert(serializeFrontmatter(parsed.fields, parsed.extras) === text, 'serialize(parse(x)) != x');
606
-
607
- const crlfText = serializeFrontmatter(fields, extras, '\r\n');
608
- const crlfParsed = parseFrontmatter(crlfText);
609
- assert(crlfParsed.eol === '\r\n', `CRLF eol: ${JSON.stringify(crlfParsed.eol)}`);
610
- assert(serializeFrontmatter(crlfParsed.fields, crlfParsed.extras, crlfParsed.eol) === crlfText, 'CRLF round-trip changed bytes');
611
-
612
- const crlfCard = [
613
- '---',
614
- 'title: 旧标题',
615
- 'tags: [a, 设计]',
616
- 'strength: 3',
617
- 'last_used: 2026-10-01',
618
- 'revisions: 1',
619
- 'custom: keep-me',
620
- '---',
621
- '# 标题',
622
- '',
623
- '正文:含冒号。',
624
- '',
625
- ].join('\r\n');
626
- const updated = updateFrontmatter(crlfCard, { title: '新标题', strength: 6 });
627
- assert(updated.includes('title: 新标题\r\n') && updated.includes('strength: 6\r\n'), 'updated fields missing');
628
- assert(updated.includes('custom: keep-me\r\n'), 'unknown frontmatter key was dropped');
629
- assert(updated.endsWith('# 标题\r\n\r\n正文:含冒号。\r\n'), 'body bytes changed');
630
- assert(!updated.replace(/\r\n/g, '').includes('\n'), 'mixed EOL was introduced');
631
- const reparsed = parseCardForAssertions(updated);
632
- assert(reparsed.fields.title === '新标题' && reparsed.fields.strength === '6', 'updated fields do not parse');
633
- assert(updated.includes('tags: [a, 设计]\r\n') && updated.includes('revisions: 1\r\n'), 'untouched fields changed');
634
- assert(updated.includes('custom: keep-me\r\n'), 'extras did not survive the update');
635
-
636
- const lfCard = [
637
- '---', 'title: 要删', 'tags: [x]', 'strength: 2',
638
- 'last_used: 2026-10-01', 'revisions: 0', '---', 'BODY', '',
639
- ].join('\n');
640
- const deleted = updateFrontmatter(lfCard, { title: null, tags: [] });
641
- assert(!deleted.includes('title:') && !deleted.includes('tags:'), 'deleted fields still present');
642
- assert(deleted.includes('strength: 2\n') && deleted.endsWith('BODY\n'), 'deleting fields damaged the card');
643
- const deletedParsed = parseCardForAssertions(deleted);
644
- assert(!('title' in deletedParsed.fields) && !('tags' in deletedParsed.fields), 'deleted fields still parse');
645
-
646
- assert(appendLine('a', 'b') === 'a\nb\n', 'appendLine LF');
647
- assert(appendLine('a\n', 'b') === 'a\nb\n', 'appendLine trailing LF');
648
- assert(appendLine('a\r\n', 'b') === 'a\r\nb\r\n', 'appendLine CRLF');
649
- assert(appendLine('', 'b') === 'b\n', 'appendLine empty');
650
- assert(isRevLine('<!-- rev: 2026-11-02 why -->') === true, 'canonical rev line not recognized');
651
- assert(isRevLine(' <!-- REV : x -->') === true, 'indented/case-insensitive rev line not recognized');
652
- assert(isRevLine('<!-- revx: no -->') === false, 'non-rev comment misdetected');
653
- const revBody = 'A\n<!-- rev: 1 x -->\n\nB\n\n';
654
- assert(countRevLines(revBody) === 1, 'rev line count');
655
- assert(normalizedBody(revBody) === 'A\n\nB', `normalizedBody: ${JSON.stringify(normalizedBody(revBody))}`);
656
-
657
- const empty = parseFrontmatter('---\ntitle:\ndate:\ntags:\nsalience:\nstrength:\nlast_used:\nrevisions:\nextra:\n---\nBODY');
658
- assert(empty.fields.title === null && empty.fields.date === null && empty.fields.last_used === null, 'empty string fields should be null');
659
- assert(empty.fields.tags.length === 0, 'empty tags should be []');
660
- assert(empty.fields.salience === DEFAULT_FIELDS.salience && empty.fields.strength === DEFAULT_FIELDS.strength, 'empty numerics must take defaults, not the clamp floor');
661
- assert(empty.fields.revisions === DEFAULT_FIELDS.revisions, 'empty revisions must take the default 0');
662
- assert(empty.extras.extra === '', 'empty unknown value should stay an empty string');
663
- const emptySerialized = serializeFrontmatter({ ...DEFAULT_FIELDS, tags: [] }, { extra: '' });
664
- const emptyRound = parseFrontmatter(`${emptySerialized}\nBODY`);
665
- assert(emptyRound.fields.salience === 3 && emptyRound.fields.strength === 3, 'defaults did not serialize/parse');
666
- assert(emptyRound.fields.title === null && emptyRound.fields.tags.length === 0, 'empty fields did not stay empty');
667
-
668
- expectCode(() => parseFrontmatter(42), 'E_BAD_ARGUMENT');
669
- expectCode(() => parseFrontmatter(null), 'E_BAD_ARGUMENT');
670
- expectCode(() => parseFrontmatter(undefined), 'E_BAD_ARGUMENT');
671
- expectCode(() => parseFrontmatter('---\ntitle: x\n'), 'E_FRONTMATTER_UNTERMINATED');
672
- const malformed = parseFrontmatter([
673
- '---', '9bad: x', 'bad key: x', 'no colon', 'title: "a: b"',
674
- 'tags: [one, , two]', 'salience: 9', 'strength: -4', 'revisions: 1.9',
675
- 'Extra-Key: kept', '# comment', '---', 'BODY',
676
- ].join('\n'));
677
- assert(malformed.fields.title === 'a: b', `quoted colon title: ${JSON.stringify(malformed.fields.title)}`);
678
- assert(malformed.fields.tags.join('|') === 'one|two', `tags holes: ${malformed.fields.tags.join('|')}`);
679
- assert(malformed.fields.salience === 9 && malformed.fields.strength === 0 && malformed.fields.revisions === 1, 'clamping failed');
680
- assert(malformed.extras['Extra-Key'] === 'kept' && !('9bad' in malformed.extras), 'bad keys leaked into extras');
681
- // status/salience are validated, not silently clamped: an out-of-range or
682
- // non-integer salience is a coded error, and so is an unknown status.
683
- expectCode(() => parseFrontmatter('---\nsalience: 99\n---\nBODY'), 'E_CARD_SALIENCE_INVALID');
684
- expectCode(() => parseFrontmatter('---\nsalience: -1\n---\nBODY'), 'E_CARD_SALIENCE_INVALID');
685
- expectCode(() => parseFrontmatter('---\nstatus: frozen\n---\nBODY'), 'E_CARD_STATUS_INVALID');
686
-
687
- // The on-disk format is single-line only. A continuation line is now a
688
- // coded error instead of being dropped silently, and the write side refuses
689
- // values containing CR/LF so a card can always round-trip.
690
- expectCode(() => parseFrontmatter('---\ntitle: 第一行\n 第二行\n---\nBODY'), 'E_FRONTMATTER_MULTILINE');
691
- expectCode(() => serializeFrontmatter({ ...DEFAULT_FIELDS, title: '第一行\n第二行' }, {}), 'E_BAD_ARGUMENT');
692
- expectCode(() => updateFrontmatter('---\ntitle: x\n---\nBODY', { title: 'a\nb' }), 'E_BAD_ARGUMENT');
693
-
694
- const bomText = '\uFEFF---\r\ntitle: 中文\r\ntags: [a, 设计]\r\ncustom: keep\r\n---\r\n# 标题\r\n\r\n正文:含冒号。\r\n';
695
- const bomParsed = parseFrontmatter(bomText);
696
- assert(bomParsed.bom === '\uFEFF' && bomParsed.eol === '\r\n', 'BOM/CRLF not detected');
697
- assert(bomParsed.fields.title === '中文' && bomParsed.fields.tags.join('|') === 'a|设计', 'BOM card fields changed');
698
- assert(bomParsed.extras.custom === 'keep', 'BOM card extras changed');
699
- assert(bomParsed.body.startsWith('# 标题\r\n'), 'BOM card body lost its heading');
700
- assert(bomParsed.text.slice(bomParsed.bodyStart) === bomParsed.body, 'bodyStart is not an index into text');
701
- // updateFrontmatter on a BOM card is covered by M13 (round-trip and rollback).
702
- return 'canonical/CRLF round-trip, deletion, empty values, multiline limit and malformed input covered';
703
- });
704
- }
705
-
706
- function m8State(runCase, assert, expectCode, TMP) {
707
- runCase('M8', 'state: defaults, merge/backup, atomic write rollback, corrupt state, audit/list', () => {
708
- const root = fixture(TMP, 'm8-state');
709
- const defaults = readState(root);
710
- assert(defaults.lastConsolidated === null && defaults.indexBuiltAt === null, 'missing state should give null fields');
711
- assert(Array.isArray(defaults.forgetRuns) && defaults.forgetRuns.length === 0, 'missing state should give empty forgetRuns');
712
-
713
- const first = updateState(root, { lastConsolidated: '2026-10-31' }, { now: FIXED_NOW });
714
- assert(first.write.created === true, 'first update should create the state file');
715
- assert(first.updatedAt === FIXED_NOW.toISOString(), 'updatedAt is not the injected clock');
716
- assert(readState(root).lastConsolidated === '2026-10-31', 'first update was not persisted');
717
-
718
- const second = updateState(root, { indexBuiltAt: '2026-11-02T01:00:00.000Z' }, { now: new Date('2026-11-02T04:00:00Z') });
719
- assert(second.write.created === false && second.write.backup, 'second update should back up the previous state');
720
- const merged = readState(root);
721
- assert(merged.lastConsolidated === '2026-10-31' && merged.indexBuiltAt === '2026-11-02T01:00:00.000Z', 'updateState did not merge');
722
- assert(backupNames(root).filter((name) => name.startsWith('memory-state.')).length === 1, 'state backup missing');
723
-
724
- const atomicAbs = put(root, 'topics/atomic.md', 'ORIGINAL\n');
725
- const rewrite = writeFileAtomic(root, 'topics/atomic.md', 'REPLACED\n', { now: FIXED_NOW });
726
- assert(rewrite.created === false && rewrite.bytes === 9 && rewrite.sha256 === sha('REPLACED\n'), 'rewrite result metadata wrong');
727
- assert(fs.readFileSync(atomicAbs, 'utf8') === 'REPLACED\n', 'rewrite did not land');
728
- assert(backupNames(root).some((name) => name.startsWith('atomic.')), 'rewrite did not back up the old bytes');
729
- const created = writeFileAtomic(root, 'topics/new-atomic.md', 'NEW\n', { now: FIXED_NOW });
730
- assert(created.created === true && fs.readFileSync(created.path, 'utf8') === 'NEW\n', 'create did not land');
731
- const leftovers = () => fs.readdirSync(path.join(root, 'topics')).filter((name) => name.includes('.tmp-'));
732
- assert(leftovers().length === 0, 'successful writes left tmp files');
733
-
734
- const beforeFailure = fs.readFileSync(atomicAbs, 'utf8');
735
- const backupsBefore = backupNames(root).length;
736
- const renameError = withPatchedFs({
737
- renameSync: () => () => { throw Object.assign(new Error('simulated rename EACCES'), { code: 'EACCES' }); },
738
- }, () => expectCode(() => writeFileAtomic(root, 'topics/atomic.md', 'HALF-WRITTEN\n', { now: FIXED_NOW }), 'EACCES'));
739
- assert(renameError.code === 'EACCES', 'rename fault did not surface its code');
740
- assert(fs.readFileSync(atomicAbs, 'utf8') === beforeFailure, 'failed rename changed the destination');
741
- assert(leftovers().length === 0, 'failed rename left a tmp file');
742
- assert(backupNames(root).length === backupsBefore + 1, 'failed write did not keep its pre-write backup');
743
-
744
- const beforeReadBack = fs.readFileSync(atomicAbs, 'utf8');
745
- let armed = false;
746
- const readBackError = withPatchedFs({
747
- renameSync: (orig) => (from, to) => {
748
- const result = orig(from, to);
749
- if (path.resolve(to) === path.resolve(atomicAbs)) armed = true;
750
- return result;
751
- },
752
- readFileSync: (orig) => (file, ...args) => {
753
- if (armed && path.resolve(String(file)) === path.resolve(atomicAbs)) {
754
- armed = false;
755
- return 'CORRUPTED READBACK';
756
- }
757
- return orig(file, ...args);
758
- },
759
- }, () => expectCode(() => writeFileAtomic(root, 'topics/atomic.md', 'SHOULD-ROLL-BACK\n', { now: FIXED_NOW }), 'E_WRITE_VERIFY'));
760
- assert(readBackError.code === 'E_WRITE_VERIFY', 'verify fault did not surface E_WRITE_VERIFY');
761
- assert(fs.readFileSync(atomicAbs, 'utf8') === beforeReadBack, 'read-back failure did not restore the original bytes');
762
- assert(leftovers().length === 0, 'read-back failure left a tmp file');
763
-
764
- put(root, 'topics/tamper.md', 'SOURCE\n');
765
- const tamperError = withPatchedFs({
766
- copyFileSync: (orig) => (from, to) => {
767
- orig(from, to);
768
- fs.writeFileSync(to, 'TAMPERED\n');
769
- },
770
- }, () => expectCode(() => backupFile(root, 'topics/tamper.md', { now: FIXED_NOW }), 'E_BACKUP_VERIFY'));
771
- assert(tamperError.code === 'E_BACKUP_VERIFY', 'tampered backup was accepted');
772
- assert(!backupNames(root).some((name) => name.startsWith('tamper.')), 'tampered backup copy was left behind');
773
-
774
- fs.writeFileSync(statePath(root), '{not json', 'utf8');
775
- expectCode(() => readState(root), 'E_STATE_CORRUPT');
776
- fs.writeFileSync(statePath(root), '[]', 'utf8');
777
- expectCode(() => readState(root), 'E_STATE_CORRUPT');
778
- fs.writeFileSync(statePath(root), 'null', 'utf8');
779
- const nullState = readState(root);
780
- assert(nullState.lastConsolidated === null && nullState.forgetRuns.length === 0, 'JSON null should behave like an empty state');
781
-
782
- const audit = appendAudit(root, { action: 'a\nb', object: 'obj\tx', result: 'ok\r\n', now: FIXED_NOW });
783
- const auditLines = fs.readFileSync(audit.path, 'utf8').trim().split(/\r?\n/);
784
- assert(auditLines.length === 1, `audit should write exactly one line, got ${auditLines.length}`);
785
- const parts = auditLines[0].split('\t');
786
- assert(parts.length === 4, `audit should have 4 tab-separated fields, got ${parts.length}`);
787
- assert(parts[0] === FIXED_NOW.toISOString(), 'audit timestamp wrong');
788
- assert(parts[1] === 'a b' && parts[2] === 'obj x' && parts[3].trim() === 'ok', 'audit newlines/tabs were not sanitized');
789
-
790
- put(root, 'topics/nested/deep.md', 'DEEP\n');
791
- put(root, 'topics/skip.txt', 'TXT\n');
792
- put(root, 'topics/.hidden.md', 'HIDDEN\n');
793
- const listed = listMarkdownFiles(root, 'topics').map((abs) => path.relative(root, abs).split(path.sep).join('/'));
794
- assert(listed.includes('topics/atomic.md') && listed.includes('topics/nested/deep.md'), 'recursive listing missed a card');
795
- assert(!listed.includes('topics/skip.txt') && !listed.some((rel) => rel.includes('.hidden')), 'listing did not skip non-md/dot files');
796
- assert(listMarkdownFiles(root, 'missing').length === 0, 'missing directory should list nothing');
797
- return 'state merge, backup, rename/read-back failures, corrupt JSON, audit sanitization and recursive listing covered';
798
- });
799
- }
800
-
801
- function m9ConsolidateRules(runCase, assert, expectCode, TMP) {
802
- runCase('M9', 'consolidate: salience rules, target scoring, excerpt, since/undated, report only', () => {
803
- const root = fixture(TMP, 'm9-consolidate-rules');
804
- put(root, 'topics/记忆解剖.md', [
805
- '---',
806
- 'title: 记忆解剖',
807
- 'date: 2026-10-20',
808
- 'tags: [memory, design]',
809
- 'salience: 5',
810
- 'strength: 4',
811
- 'last_used: 2026-10-30',
812
- 'revisions: 2',
813
- '---',
814
- '# 记忆解剖',
815
- '',
816
- 'salience 与 strength 是记忆的两个维度。',
817
- '',
818
- ].join('\n'));
819
- put(root, 'topics/工程约定.md', [
820
- '---',
821
- 'title: 工程约定',
822
- 'date: 2026-10-20',
823
- 'tags: [engineering]',
824
- 'salience: 3',
825
- 'strength: 3',
826
- 'last_used: 2026-10-01',
827
- 'revisions: 1',
828
- '---',
829
- '# 工程约定',
830
- '',
831
- '所有写入先备份,命名用中文。',
832
- '',
833
- ].join('\n'));
834
- put(root, 'journal/2026-10-31.md', [
835
- '---',
836
- 'date: 2026-10-31',
837
- '---',
838
- '# 旧日志',
839
- '',
840
- '这条在增量起点之前,不该被读到。',
841
- '',
842
- ].join('\n'));
843
- put(root, 'journal/2026-11-01.md', [
844
- '# 记录',
845
- '',
846
- '铁律:永远不许骗他,这是必须守住的底线。',
847
- '',
848
- '## 小标题',
849
- '',
850
- '决定:所有写入先备份。',
851
- '',
852
- '<!-- 备注:不该出现 -->',
853
- '',
854
- '记忆解剖里的 salience 与 design 标签要保留。',
855
- '',
856
- '随手记:明天买牛奶。',
857
- '',
858
- `这一段是流水账。\n${'内容'.repeat(80)}`,
859
- '',
860
- ].join('\n'));
861
- put(root, 'journal/undated.md', [
862
- '# 无日期',
863
- '',
864
- '没有日期的段落。',
865
- '',
866
- ].join('\n'));
867
- put(root, '.longlive/memory-state.json', `${JSON.stringify({
868
- lastConsolidated: '2026-10-31T12:00:00.000Z',
869
- indexBuiltAt: null,
870
- forgetRuns: [],
871
- }, null, 2)}\n`);
872
-
873
- const config = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
874
- const skip = ['.longlive/'];
875
- const before = snapshot(root, skip);
876
- const result = memoryConsolidate({}, config);
877
- assert(result.since === '2026-10-31', `state timestamp should reduce to 2026-10-31, got ${result.since}`);
878
- assert(result.entries === 5, `expected 5 read paragraphs, got ${result.entries}`);
879
- assert(result.proposals.length === 5, `expected 5 proposals, got ${result.proposals.length}`);
880
- assert(result.undated.length === 1 && result.undated[0].source === 'journal/undated.md', 'undated paragraph not isolated');
881
-
882
- const proposals = result.proposals;
883
- assert(proposals.map((item) => item.suggestedSalience).join(',') === '5,4,3,2,3', `salience rules: ${proposals.map((item) => item.suggestedSalience).join(',')}`);
884
- assert(proposals.map((item) => item.lineStart).join(',') === '3,7,11,13,15', `unit line numbers: ${proposals.map((item) => item.lineStart).join(',')}`);
885
- assert(proposals[0].target === null && proposals[0].reason.includes('建议新建卡'), `铁律 paragraph should not match a card: ${proposals[0].reason}`);
886
- assert(proposals[1].target === 'topics/工程约定.md' && proposals[1].targetTitle === '工程约定', `backup paragraph target: ${proposals[1].target}`);
887
- assert(proposals[1].reason.startsWith('原文引用『') && proposals[1].reason.includes('话题命中'), `backup paragraph reason: ${proposals[1].reason}`);
888
- assert(proposals[2].target === 'topics/记忆解剖.md' && proposals[2].targetTitle === '记忆解剖', `memory paragraph target: ${proposals[2].target}`);
889
- assert(proposals[2].reason.includes('原文引用') && proposals[2].reason.includes('话题命中'), `memory paragraph reason: ${proposals[2].reason}`);
890
- assert(proposals[2].quote === '记忆解剖里的 salience 与 design 标签要保留。', `quote must be the routed sentence: ${proposals[2].quote}`);
891
- assert(proposals[3].target === null && proposals[4].target === null, 'low-value paragraphs should not force a target');
892
- assert(proposals[4].excerpt === proposals[4].claim.slice(0, 120) && proposals[4].claim.length > 0, `excerpt must derive from the claim sentence: ${proposals[4].excerpt.slice(0, 40)}`);
893
- assert(proposals.every((item) => item.excerpt.length <= 120 && !item.excerpt.includes('\n')), 'excerpt length/newlines');
894
- assert(proposals.every((item) => item.quote === null || item.quote.length <= 120), 'quote length');
895
- assert(proposals.every((item) => item.source === 'journal/2026-11-01.md' && item.date === '2026-11-01'), 'journal date fallback is wrong');
896
- assert(proposals.every((item) => !item.excerpt.startsWith('#') && !item.excerpt.includes('<!--')), 'headings/comments leaked into excerpts');
897
-
898
- const reportAbs = path.join(root, '.longlive', 'reports', 'consolidate-2026-11-02.md');
899
- assert(result.reportRel === '.longlive/reports/consolidate-2026-11-02.md', `reportRel: ${result.reportRel}`);
900
- assert(fs.existsSync(reportAbs), 'report file was not written');
901
- const report = fs.readFileSync(reportAbs, 'utf8');
902
- assert(report.includes('提案') && report.includes('topics/记忆解剖.md') && report.includes('建议 salience'), 'report is missing proposal details');
903
- assert(report.includes('- 原文引用:') && report.includes('- 结论句:') && report.includes('- 行号:3'), 'report is missing quote/claim/line details');
904
- assert(result.sha256 === sha(report), 'report sha256 does not match the file');
905
- sameSnapshot(assert, before, snapshot(root, skip), 'consolidate report-only');
906
- const stateAfterFirst = JSON.parse(fs.readFileSync(path.join(root, '.longlive', 'memory-state.json'), 'utf8'));
907
- assert(stateAfterFirst.lastConsolidated === '2026-10-31T12:00:00.000Z', 'consolidate advanced memory-state.json with unresolved proposals');
908
- assert(stateAfterFirst.seenProposals.length === 5 && stateAfterFirst.seenProposals.every((entry) => entry.status === 'proposed'), 'proposal ids were not recorded');
909
-
910
- expectCode(() => memoryConsolidate({ apply: true }, config), 'E_READ_ONLY');
911
-
912
- const second = memoryConsolidate({ since: '2026-11-01' }, config);
913
- assert(second.since === '2026-11-01' && second.entries === 0 && second.proposals.length === 0, 'explicit since did not cut the journal');
914
- assert(second.undated.length === 1, 'undated paragraph should survive an explicit since');
915
- const backupDir = path.join(root, '.longlive', 'reports', '.backups');
916
- assert(fs.existsSync(backupDir) && fs.readdirSync(backupDir).length === 1, 'overwriting the report did not back it up');
917
-
918
- const invalidSince = memoryConsolidate({ since: 'not-a-day' }, config);
919
- assert(invalidSince.since === null, `invalid since should fall back to null, got ${invalidSince.since}`);
920
- assert(invalidSince.entries === 7, `invalid since should scan everything, got ${invalidSince.entries} paragraphs`);
921
- assert(invalidSince.proposals.length === 2 && invalidSince.duplicatesSuppressed === 5,
922
- `invalid since should only emit unseen paragraphs: ${invalidSince.proposals.length} new / ${invalidSince.duplicatesSuppressed} seen`);
923
- assert(invalidSince.undated.length === 0, 'invalid since has no incremental start, so nothing is "undated"');
924
- // A fixed clock makes all three reports share one timestamp. The backup name
925
- // must still disambiguate, or the third call silently eats the second
926
- // call's backup.
927
- const backups = fs.readdirSync(backupDir).sort();
928
- assert(backups.length === 2, `fixed-clock report backups must not collide, got ${backups.length}`);
929
- assert(new Set(backups).size === 2, 'report backup names are not unique');
930
- const backupTexts = backups.map((name) => fs.readFileSync(path.join(backupDir, name), 'utf8'));
931
- assert(backupTexts.some((text) => text.includes('增量起点:2026-10-31')), 'first report backup was lost');
932
- assert(backupTexts.some((text) => text.includes('增量起点:2026-11-01')), 'second report backup was lost');
933
- sameSnapshot(assert, before, snapshot(root, skip), 'consolidate since variants');
934
- return '5 proposals mapped to expected targets/salience; since, dedup, undated, report backup and read-only guard covered';
935
- });
936
- }
937
-
938
- function m10ReinforceDecay(runCase, assert, TMP) {
939
- runCase('M10', 'reinforce: 0/1/many uses, decay recovery, cap 9, body preserved', () => {
940
- const root = fixture(TMP, 'm10-decay');
941
- put(root, 'topics/heat.md', cardFile({ title: '热卡', strength: 2, lastUsed: '2026-08-01', body: '热卡正文。' }));
942
- put(root, 'topics/ancient-used.md', cardFile({ title: '老卡', strength: 4, lastUsed: '2024-01-01', body: '老卡正文。' }));
943
- const config = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
944
-
945
- const heatAbs = path.join(root, 'topics/heat.md');
946
- const originalBody = parseCardForAssertions(fs.readFileSync(heatAbs, 'utf8')).body;
947
- const dry0 = memoryForget({}, config);
948
- const heat0 = dry0.proposals.find((item) => item.path === 'topics/heat.md');
949
- const ancient0 = dry0.proposals.find((item) => item.path === 'topics/ancient-used.md');
950
- assert(heat0 && heat0.kind === 'epochs' && heat0.ageDays === 93, `93-day card: ${JSON.stringify(heat0)}`);
951
- assert(ancient0 && ancient0.kind === 'tombstone', 'two-year card should be a tombstone proposal');
952
- assert(parseCardForAssertions(fs.readFileSync(heatAbs, 'utf8')).fields.strength === '2', '0 uses must not change strength');
953
-
954
- const heat1 = memoryReinforce({ path: 'topics/heat.md', reason: '第一次真实使用' }, config);
955
- assert(heat1.before.strength === 2 && heat1.after.strength === 3, 'one use should add exactly 1 strength');
956
- assert(heat1.after.last_used === FIXED_DAY && heat1.after.revisions === 1, 'one use should refresh last_used and revisions');
957
- assert(heat1.capped === false && heat1.bodyIdentical === true, 'one use should not cap or touch the body');
958
- const dry1Heat = memoryForget({}, config);
959
- assert(!dry1Heat.proposals.some((item) => item.path === 'topics/heat.md'), 'one citation did not clear the decay proposal');
960
- assert(dry1Heat.proposals.some((item) => item.path === 'topics/ancient-used.md'), 'untouched old card should still be proposed');
961
-
962
- const ancient1 = memoryReinforce({ path: 'topics/ancient-used.md', reason: '重新使用' }, config);
963
- assert(ancient1.after.last_used === FIXED_DAY && ancient1.after.strength === 5, 'old card did not recover after a citation');
964
- const dry1 = memoryForget({}, config);
965
- assert(dry1.proposals.length === 0, `both cards should be safe now, got ${dry1.proposals.length}`);
966
-
967
- const strengths = [heat1.after.strength];
968
- let cappedAt = null;
969
- for (let call = 2; call <= 9; call += 1) {
970
- const result = memoryReinforce({ path: 'topics/heat.md', reason: `第${call}次引用` }, config);
971
- strengths.push(result.after.strength);
972
- if (result.capped && cappedAt === null) cappedAt = call;
973
- }
974
- assert(strengths.join(',') === '3,4,5,6,7,8,9,9,9', `strength sequence: ${strengths.join(',')}`);
975
- assert(cappedAt === 8, `capped flag first true at call ${cappedAt}`);
976
-
977
- const finalCard = parseCardForAssertions(fs.readFileSync(heatAbs, 'utf8'));
978
- assert(finalCard.fields.strength === '9' && finalCard.fields.revisions === '9', `final fields: strength=${finalCard.fields.strength} revisions=${finalCard.fields.revisions}`);
979
- assert(finalCard.fields.salience === '3' && finalCard.fields.last_used === FIXED_DAY, 'salience/last_used changed unexpectedly');
980
- assert(countRevLines(finalCard.body) === 9, `rev lines: ${countRevLines(finalCard.body)}`);
981
- assert(normalizedBody(finalCard.body) === normalizedBody(originalBody), 'reinforce changed the prose body');
982
- assert(memoryForget({}, config).proposals.length === 0, 'repeated citations should keep the card safe');
983
- return '0/1/9 citations: 93-day proposal clears after one use, strength caps at 9, body byte-identical';
984
- });
985
- }
986
-
987
- function m11ForgetThresholds(runCase, assert, expectCode, TMP) {
988
- runCase('M11', 'forget: 90/365 boundaries, immune core, tombstone-before-move, failure state', () => {
989
- const root = fixture(TMP, 'm11-thresholds');
990
- const cards = {
991
- 'topics/age90.md': cardFile({ title: '九十天', strength: 1, lastUsed: '2026-08-04', body: '刚好九十天。' }),
992
- 'topics/age91low.md': cardFile({ title: '九十一天低', strength: 2, lastUsed: '2026-08-03', body: '低强度。' }),
993
- 'topics/age91high.md': cardFile({ title: '九十一天高', strength: 3, lastUsed: '2026-08-03', body: '高强度。' }),
994
- 'topics/age365low.md': cardFile({ title: '一年低', strength: 1, lastUsed: '2025-11-02', body: '一年低强度。' }),
995
- 'topics/age365high.md': cardFile({ title: '一年高', strength: 9, lastUsed: '2025-11-02', body: '一年高强度。' }),
996
- 'topics/age366.md': cardFile({ title: '超过一年', strength: 9, lastUsed: '2025-11-01', body: '超过一年。' }),
997
- 'topics/core.md': cardFile({ title: '核心', salience: 5, strength: 0, lastUsed: '2020-01-01', body: '核心记忆。' }),
998
- 'topics/undated.md': cardFile({ title: '没日期', strength: 0, body: '没有 last_used。' }),
999
- 'topics/fresh.md': cardFile({ title: '新鲜', strength: 0, lastUsed: '2026-11-01', body: '昨天用过。' }),
1000
- };
1001
- const originals = new Map();
1002
- for (const [rel, text] of Object.entries(cards)) {
1003
- put(root, rel, text);
1004
- originals.set(rel, text);
1005
- }
1006
- const config = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
1007
- const before = snapshot(root);
1008
- const dry = memoryForget({}, config);
1009
- assert(dry.proposals.length === 3, `expected 3 proposals, got ${dry.proposals.length}`);
1010
- assert(dry.proposals.map((item) => item.path).join(',') === 'topics/age366.md,topics/age365low.md,topics/age91low.md', `proposal order: ${dry.proposals.map((item) => item.path).join(',')}`);
1011
- assert(dry.proposals.map((item) => item.kind).join(',') === 'tombstone,epochs,epochs', 'kind mapping is wrong');
1012
- assert(dry.proposals[1].ageDays === 365 && dry.proposals[2].ageDays === 91, 'age boundaries are off');
1013
- assert(!dry.proposals.some((item) => ['topics/age90.md', 'topics/age91high.md', 'topics/age365high.md', 'topics/fresh.md'].includes(item.path)), 'a below-threshold card was proposed');
1014
- assert(dry.immune.length === 1 && dry.immune[0].path === 'topics/core.md', 'core memory is not immune');
1015
- assert(dry.undated.length === 1 && dry.undated[0].path === 'topics/undated.md', 'undated card handling is wrong');
1016
- sameSnapshot(assert, before, snapshot(root), 'forget boundary dry-run');
1017
- assert(!fs.existsSync(path.join(root, 'tombstones.md')), 'dry-run wrote tombstones.md');
1018
-
1019
- const done = memoryForget({ confirm: 'FORGET' }, config);
1020
- assert(done.applied === true && done.moved === 3 && done.tombstoneLines === 3, `execute moved=${done.moved} lines=${done.tombstoneLines}`);
1021
- assert(fs.existsSync(path.join(root, 'archive/tombstones/topics/age366.md')), 'tombstoned card missing');
1022
- assert(fs.existsSync(path.join(root, 'epochs/topics/age365low.md')), '365-day low-strength card missing');
1023
- assert(fs.existsSync(path.join(root, 'epochs/topics/age91low.md')), '91-day low-strength card missing');
1024
- assert(fs.readFileSync(path.join(root, 'archive/tombstones/topics/age366.md'), 'utf8') === originals.get('topics/age366.md'), 'tombstone copy changed bytes');
1025
- assert(fs.readFileSync(path.join(root, 'epochs/topics/age365low.md'), 'utf8') === originals.get('topics/age365low.md'), 'epochs copy changed bytes');
1026
- for (const rel of ['topics/age90.md', 'topics/age91high.md', 'topics/age365high.md', 'topics/core.md', 'topics/undated.md', 'topics/fresh.md']) {
1027
- assert(fs.existsSync(path.join(root, rel)), `below-threshold/immune card disappeared: ${rel}`);
1028
- }
1029
- const tombLines = fs.readFileSync(path.join(root, 'tombstones.md'), 'utf8').split(/\r?\n/).filter((line) => line.startsWith('- '));
1030
- assert(tombLines.length === 3, `tombstones.md has ${tombLines.length} lines`);
1031
- assert(tombLines.every((line) => line.includes('recoverable:')), 'tombstone line lacks a recoverable path');
1032
- assert(!tombLines.some((line) => line.includes('core.md')), 'immune core card got a tombstone line');
1033
- const state = JSON.parse(fs.readFileSync(statePath(root), 'utf8'));
1034
- assert(state.forgetRuns.length === 1 && state.forgetRuns[0].moved === 3, 'forgetRuns not recorded');
1035
- assert(fs.readFileSync(path.join(root, '.longlive', 'audit.log'), 'utf8').trim().split(/\r?\n/).length === 1, 'audit line missing');
1036
-
1037
- const noopRoot = fixture(TMP, 'm11-noop');
1038
- put(noopRoot, 'topics/age90.md', cards['topics/age90.md']);
1039
- const noopBefore = snapshot(noopRoot);
1040
- const noop = memoryForget({ confirm: 'FORGET' }, { memoryRoot: noopRoot, now: FIXED_NOW, today: FIXED_DAY });
1041
- assert(noop.status === 'noop' && noop.applied === false && noop.moved === 0, `noop status=${noop.status} moved=${noop.moved}`);
1042
- sameSnapshot(assert, noopBefore, snapshot(noopRoot), 'noop forget');
1043
- assert(!fs.existsSync(path.join(noopRoot, 'tombstones.md')), 'noop forget wrote tombstones.md');
1044
- assert(!fs.existsSync(statePath(noopRoot)), 'noop forget wrote state');
1045
- assert(!fs.existsSync(path.join(noopRoot, '.longlive', 'audit.log')), 'noop forget wrote audit');
1046
-
1047
- const orderRoot = fixture(TMP, 'm11-order');
1048
- put(orderRoot, 'topics/ancient.md', cards['topics/age366.md']);
1049
- let captured = null;
1050
- withPatchedFs({
1051
- renameSync: (orig) => (from, to) => {
1052
- if (String(from).endsWith('ancient.md')) {
1053
- const tomb = path.join(orderRoot, 'tombstones.md');
1054
- captured = {
1055
- sourceExists: fs.existsSync(from),
1056
- tomb: fs.existsSync(tomb) ? fs.readFileSync(tomb, 'utf8') : '',
1057
- to,
1058
- };
1059
- }
1060
- return orig(from, to);
1061
- },
1062
- }, () => memoryForget({ confirm: 'FORGET' }, { memoryRoot: orderRoot, now: FIXED_NOW, today: FIXED_DAY }));
1063
- assert(captured, 'card move was never observed');
1064
- assert(captured.sourceExists === true, 'source card vanished before the move');
1065
- assert(captured.tomb.includes('topics/ancient.md') && captured.tomb.includes('recoverable: archive/tombstones/topics/ancient.md'), 'tombstone line was not written before the move');
1066
- assert(String(captured.to).endsWith(path.join('archive', 'tombstones', 'topics', 'ancient.md')), `unexpected archive destination: ${captured.to}`);
1067
-
1068
- const failRoot = fixture(TMP, 'm11-move-fail');
1069
- put(failRoot, 'topics/ancient.md', cards['topics/age366.md']);
1070
- const moveError = withPatchedFs({
1071
- renameSync: (orig) => (from, to) => {
1072
- if (String(from).endsWith('ancient.md')) {
1073
- throw Object.assign(new Error('simulated move failure'), { code: 'EACCES' });
1074
- }
1075
- return orig(from, to);
1076
- },
1077
- }, () => expectCode(() => memoryForget({ confirm: 'FORGET' }, { memoryRoot: failRoot, now: FIXED_NOW, today: FIXED_DAY }), 'E_FORGET_MOVE'));
1078
- assert(moveError.message.includes('墓碑已写'), 'move failure should name the written tombstone');
1079
- assert(fs.existsSync(path.join(failRoot, 'topics/ancient.md')), 'move failure lost the source card');
1080
- assert(!fs.existsSync(path.join(failRoot, 'archive/tombstones/topics/ancient.md')), 'move failure produced an archive copy');
1081
- assert(fs.readFileSync(path.join(failRoot, 'tombstones.md'), 'utf8').includes('topics/ancient.md'), 'move failure did not leave a tombstone line');
1082
- assert(!fs.existsSync(statePath(failRoot)), 'move failure wrote state');
1083
- assert(!fs.existsSync(path.join(failRoot, '.longlive', 'audit.log')), 'move failure wrote audit');
1084
- return '90/365 boundaries, core immunity, tombstone-before-move and move-failure state covered';
1085
- });
1086
- }
1087
-
1088
- function m12ToolErrors(runCase, assert, expectCode, TMP) {
1089
- runCase('M12', 'tool layer: memory_* schemas and coded errors for invalid arguments', () => {
1090
- const root = fixture(TMP, 'm12-tools');
1091
- put(root, 'topics/valid.md', cardFile({ title: '有效卡', strength: 2, lastUsed: FIXED_DAY }));
1092
- const notDir = path.join(TMP, 'm12-not-a-directory');
1093
- fs.writeFileSync(notDir, 'not a dir', 'utf8');
1094
- const missingRoot = path.join(TMP, 'm12-root-does-not-exist');
1095
- fs.rmSync(missingRoot, { recursive: true, force: true });
1096
-
1097
- const names = TOOLS.filter((tool) => tool.name.startsWith('memory_')).map((tool) => tool.name);
1098
- assert(TOOLS.length === 22, `TOOLS length ${TOOLS.length} != 22`);
1099
- assert(names.join(',') === 'memory_index,memory_consolidate,memory_reinforce,memory_forget,memory_procedure,memory_recall', `memory tool order: ${names.join(',')}`);
1100
- const byName = new Map(TOOLS.map((tool) => [tool.name, tool]));
1101
- const expectedParams = {
1102
- memory_index: ['apply'],
1103
- memory_consolidate: ['since'],
1104
- memory_reinforce: ['path', 'reason'],
1105
- memory_forget: ['confirm'],
1106
- memory_procedure: ['action', 'name', 'slug', 'trigger', 'steps', 'outcome', 'reason', 'limit'],
1107
- memory_recall: ['query', 'topK', 'room', 'status'],
1108
- };
1109
- for (const [name, keys] of Object.entries(expectedParams)) {
1110
- const tool = byName.get(name);
1111
- assert(typeof tool?.invoke === 'function', `${name}: missing invoke`);
1112
- assert(typeof tool.description === 'string' && tool.description.length > 20, `${name}: description missing`);
1113
- assert(tool.parameters && typeof tool.parameters === 'object', `${name}: parameters missing`);
1114
- for (const key of keys) {
1115
- assert(tool.parameters[key] && typeof tool.parameters[key].type === 'string', `${name}.${key}: schema entry missing`);
1116
- }
1117
- assert(tool.output && tool.output.schema && typeof tool.output.render === 'function', `${name}: output schema/render missing`);
1118
- }
1119
- assert(byName.get('memory_reinforce').parameters.path.type === 'string', 'path must be a string');
1120
- assert(byName.get('memory_forget').parameters.confirm.type === 'string', 'confirm must be a string');
1121
-
1122
- const cases = [
1123
- ['memory_index', {}, { strictMemoryRoot: true }, 'E_MEMORY_ROOT_UNSET'],
1124
- ['memory_index', {}, { memoryRoot: missingRoot }, 'E_MEMORY_ROOT_MISSING'],
1125
- ['memory_index', {}, { memoryRoot: notDir }, 'E_MEMORY_ROOT_NOT_DIR'],
1126
- ['memory_consolidate', { apply: true }, { memoryRoot: root }, 'E_READ_ONLY'],
1127
- ['memory_reinforce', {}, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1128
- ['memory_reinforce', { path: ' ' }, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1129
- ['memory_reinforce', { path: 123 }, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1130
- ['memory_reinforce', { path: '../escape.md' }, { memoryRoot: root }, 'E_PATH_OUTSIDE_ROOT'],
1131
- ['memory_reinforce', { path: 'topics/missing.md' }, { memoryRoot: root }, 'E_CARD_NOT_FOUND'],
1132
- ['memory_forget', { confirm: 'nope' }, { memoryRoot: root }, 'E_CONFIRM_REQUIRED'],
1133
- ['memory_forget', { confirm: 42 }, { memoryRoot: root }, 'E_CONFIRM_REQUIRED'],
1134
- ['memory_procedure', {}, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1135
- ['memory_procedure', { action: 'nope' }, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1136
- ['memory_procedure', { action: 'save' }, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1137
- ['memory_procedure', { action: 'save', name: '无步骤', when: '某个时刻' }, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1138
- ['memory_procedure', { action: 'get', slug: '../escape' }, { memoryRoot: root }, 'E_BAD_ARGUMENT'],
1139
- ['memory_procedure', { action: 'get', slug: 'missing' }, { memoryRoot: root }, 'E_PROCEDURE_NOT_FOUND'],
1140
- ['memory_procedure', { action: 'report', slug: 'missing', outcome: 'success' }, { memoryRoot: root }, 'E_PROCEDURE_NOT_FOUND'],
1141
- ];
1142
- const before = snapshot(root);
1143
- for (const [name, args, config, code] of cases) {
1144
- assertCodedError(assert, invokeTool(byName.get(name), args, config), code, `${name} ${JSON.stringify(args)}`);
1145
- }
1146
- sameSnapshot(assert, before, snapshot(root), 'failed tool calls wrote files');
1147
-
1148
- const dryIndex = invokeTool(byName.get('memory_index'), {}, { memoryRoot: root });
1149
- assert(dryIndex.ok === true && dryIndex.value.dryRun === true && dryIndex.value.applied === false, 'valid memory_index dry-run failed');
1150
- const dryForget = invokeTool(byName.get('memory_forget'), {}, { memoryRoot: root });
1151
- assert(dryForget.ok === true && dryForget.value.dryRun === true, 'valid memory_forget dry-run failed');
1152
- sameSnapshot(assert, before, snapshot(root), 'valid dry-run tools wrote files');
1153
-
1154
- // One real write through the same entry the host calls, then read it back.
1155
- const saved = invokeTool(byName.get('memory_procedure'), {
1156
- action: 'save', name: 'M12 卡', trigger: ['m12'], when: '契约测试时。', steps: ['存一张'],
1157
- }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
1158
- assert(saved.ok === true && saved.value.version === 1, `memory_procedure save failed: ${saved.error?.message}`);
1159
- const fetched = invokeTool(byName.get('memory_procedure'), { action: 'get', slug: saved.value.slug }, { memoryRoot: root });
1160
- assert(fetched.ok === true && fetched.value.rel === saved.value.rel, 'memory_procedure get failed');
1161
- return `${names.length} memory tools expose schemas; ${cases.length} invalid calls return coded errors without writes`;
1162
- });
1163
- }
1164
-
1165
- function m13BomRoundTrip(runCase, assert, expectCode, TMP) {
1166
- runCase('M13', 'BOM+CRLF card: update/reinforce keep one BOM, closing fence and body; failed read-back restores it', () => {
1167
- const header = [
1168
- 'title: BOM 卡',
1169
- 'tags: [a, 设计]',
1170
- 'strength: 2',
1171
- 'last_used: 2026-10-01',
1172
- 'revisions: 1',
1173
- 'custom: keep',
1174
- ].join('\r\n');
1175
- const original = `\uFEFF---\r\n${header}\r\n---\r\n# 标题\r\n\r\n正文:含冒号。\r\n`;
1176
- const bomCount = (text) => text.split('\uFEFF').length - 1;
1177
- const originalParsed = parseCardForAssertions(original);
1178
-
1179
- const updated = updateFrontmatter(original, { strength: 3 });
1180
- const updateExpected = original.replace('strength: 2\r\n', 'strength: 3\r\n');
1181
- assert(updated === updateExpected, `updateFrontmatter changed more than the requested field: ${JSON.stringify(updated)}`);
1182
- assert(bomCount(updated) === 1, 'update produced a second BOM');
1183
- assert(updated.includes('\r\n---\r\n# 标题\r\n\r\n正文:含冒号。\r\n'), 'closing fence or body heading was lost');
1184
- assert(!updated.split(/\r?\n/).includes('-'), 'a lone "-" line was written');
1185
- const updatedParsed = parseCardForAssertions(updated);
1186
- assert(updatedParsed.fields.strength === '3' && updatedParsed.fields.title === 'BOM 卡', 'updated BOM card does not parse');
1187
- assert(updatedParsed.body === '# 标题\r\n\r\n正文:含冒号。\r\n', `body changed: ${JSON.stringify(updatedParsed.body)}`);
1188
-
1189
- const root = fixture(TMP, 'm13-bom');
1190
- const cardAbs = put(root, 'topics/bom.md', original);
1191
- const result = memoryReinforce({ path: 'topics/bom.md' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
1192
- assert(result.ok === true && result.status === 'reinforced', 'reinforce on BOM+CRLF card failed');
1193
- assert(result.after.strength === 3 && result.after.revisions === 2, 'reinforce counters are wrong');
1194
- assert(backupNames(root).length === 1, 'reinforce did not leave exactly one backup');
1195
-
1196
- const afterText = fs.readFileSync(cardAbs, 'utf8');
1197
- const reinforceExpected = original
1198
- .replace('strength: 2\r\n', 'strength: 3\r\n')
1199
- .replace('last_used: 2026-10-01\r\n', `last_used: ${FIXED_DAY}\r\n`)
1200
- .replace('revisions: 1\r\n', 'revisions: 2\r\n') +
1201
- `<!-- rev: ${FIXED_DAY} 被真实使用 -->\r\n`;
1202
- assert(afterText === reinforceExpected, `reinforce rewrote the BOM card: ${JSON.stringify(afterText)}`);
1203
- assert(bomCount(afterText) === 1, 'reinforce wrote a second BOM');
1204
- assert(!afterText.split(/\r?\n/).includes('-'), 'reinforce wrote a lone "-" line');
1205
- const afterParsed = parseCardForAssertions(afterText);
1206
- assert(afterParsed.fields.title === 'BOM 卡' && afterParsed.fields.last_used === FIXED_DAY, 'reinforced BOM card fields changed');
1207
- assert(afterParsed.body.startsWith('# 标题\r\n\r\n正文:含冒号。\r\n'), 'reinforced BOM card body lost its heading');
1208
- assert(normalizedBody(afterParsed.body) === normalizedBody(originalParsed.body), 'reinforced BOM card body changed');
1209
-
1210
- // Fault injection: the third utf8 read of the card is reinforce's own
1211
- // read-back (1 = before write, 2 = writeFileAtomic verify). Strip the
1212
- // closing fence from that read; parseFrontmatter throws and the transaction
1213
- // must restore the original bytes before surfacing a coded rollback error.
1214
- const rollbackRoot = fixture(TMP, 'm13-bom-rollback');
1215
- const rollbackAbs = put(rollbackRoot, 'topics/rollback.md', original);
1216
- let rollbackReads = 0;
1217
- withPatchedFs({
1218
- readFileSync: (orig) => (file, ...rest) => {
1219
- const text = orig(file, ...rest);
1220
- if (file === rollbackAbs && rest[0] === 'utf8' && (rollbackReads += 1) === 3) {
1221
- return text.replace('\r\n---\r\n', '\r\n');
1222
- }
1223
- return text;
1224
- },
1225
- }, () => {
1226
- expectCode(
1227
- () => memoryReinforce({ path: 'topics/rollback.md' }, { memoryRoot: rollbackRoot, now: FIXED_NOW, today: FIXED_DAY }),
1228
- 'E_REINFORCE_VERIFY',
1229
- );
1230
- });
1231
- assert(fs.readFileSync(rollbackAbs, 'utf8') === original, 'failed read-back did not restore the original card');
1232
- assert(!fs.existsSync(path.join(rollbackRoot, '.longlive', 'audit.log')), 'failed reinforce wrote an audit line');
1233
-
1234
- // Same corruption, but the restore copy itself fails -> E_ROLLBACK_FAILED.
1235
- const failRoot = fixture(TMP, 'm13-bom-rollback-fail');
1236
- const failAbs = put(failRoot, 'topics/fail.md', original);
1237
- let failReads = 0;
1238
- withPatchedFs({
1239
- readFileSync: (orig) => (file, ...rest) => {
1240
- const text = orig(file, ...rest);
1241
- if (file === failAbs && rest[0] === 'utf8' && (failReads += 1) === 3) {
1242
- return text.replace('\r\n---\r\n', '\r\n');
1243
- }
1244
- return text;
1245
- },
1246
- copyFileSync: (orig) => (from, to, ...rest) => {
1247
- if (to === failAbs) {
1248
- const error = new Error('simulated restore failure');
1249
- error.code = 'E_TEST_COPY';
1250
- throw error;
1251
- }
1252
- return orig(from, to, ...rest);
1253
- },
1254
- }, () => {
1255
- expectCode(
1256
- () => memoryReinforce({ path: 'topics/fail.md' }, { memoryRoot: failRoot, now: FIXED_NOW, today: FIXED_DAY }),
1257
- 'E_ROLLBACK_FAILED',
1258
- );
1259
- });
1260
- return 'BOM+CRLF update/reinforce byte-exact; throwing read-back rolls back; failed restore reports E_ROLLBACK_FAILED';
1261
- });
1262
- }
1263
-
1264
- function m14MissingFieldEol(runCase, assert, TMP) {
1265
- runCase('M14', 'minimal card gains missing fields with an EOL before the closing fence (LF and CRLF)', () => {
1266
- for (const eol of ['\n', '\r\n']) {
1267
- const minimal = ['---', 'title: 极小卡', 'strength: 5', '---', '正文', ''].join(eol);
1268
- const updated = updateFrontmatter(minimal, { last_used: FIXED_DAY, revisions: 1 });
1269
- const expected = [
1270
- '---', 'title: 极小卡', 'strength: 5',
1271
- `last_used: ${FIXED_DAY}`, 'revisions: 1', '---', '正文', '',
1272
- ].join(eol);
1273
- assert(updated === expected, `${JSON.stringify(eol)} minimal card was corrupted: ${JSON.stringify(updated)}`);
1274
- assert(!updated.includes('1---'), 'closing fence was glued to the revisions value');
1275
- const parsed = parseCardForAssertions(updated);
1276
- assert(parsed.fields.last_used === FIXED_DAY && parsed.fields.revisions === '1', 'new fields do not parse');
1277
- assert(parsed.body === `正文${eol}`, `body changed: ${JSON.stringify(parsed.body)}`);
1278
-
1279
- const root = fixture(TMP, `m14-minimal-${eol === '\n' ? 'lf' : 'crlf'}`);
1280
- put(root, 'topics/min.md', minimal);
1281
- const result = memoryReinforce({ path: 'topics/min.md' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
1282
- assert(result.ok === true && result.after.strength === 6 && result.after.revisions === 1, 'reinforce on a minimal card failed');
1283
- const after = parseCardForAssertions(fs.readFileSync(path.join(root, 'topics/min.md'), 'utf8'));
1284
- assert(after.fields.revisions === '1' && after.fields.last_used === FIXED_DAY, 'reinforced minimal card does not parse');
1285
- assert(after.body.includes('正文'), 'reinforced minimal card lost its body');
1286
- }
1287
- return 'minimal LF/CRLF cards append last_used/revisions with a real EOL and survive reinforce';
1288
- });
1289
- }
1290
-
1291
- function m15EmptyNumericDefaults(runCase, assert, expectCode) {
1292
- runCase('M15', 'empty numeric fields take defaults (3/3/0); salience is 0..9 with coded errors', () => {
1293
- const empty = parseFrontmatter('---\nsalience:\nstrength: \nrevisions:\t\n---\nBODY');
1294
- assert(empty.fields.salience === DEFAULT_FIELDS.salience, `empty salience: ${empty.fields.salience}`);
1295
- assert(empty.fields.strength === DEFAULT_FIELDS.strength, `empty strength: ${empty.fields.strength}`);
1296
- assert(empty.fields.revisions === DEFAULT_FIELDS.revisions, `empty revisions: ${empty.fields.revisions}`);
1297
- assert(empty.fields.status === 'active', `missing status must default to active, got ${empty.fields.status}`);
1298
-
1299
- const whitespace = parseFrontmatter('---\nsalience: \t \nstrength:\t \nrevisions: \n---\nBODY');
1300
- assert(whitespace.fields.salience === 3 && whitespace.fields.strength === 3 && whitespace.fields.revisions === 0, 'whitespace-only numerics did not take defaults');
1301
-
1302
- // A non-numeric salience is not a default any more — it is a broken card.
1303
- expectCode(() => parseFrontmatter('---\nsalience: high\nstrength: n/a\nrevisions: many\n---\nBODY'), 'E_CARD_SALIENCE_INVALID');
1304
-
1305
- // Zero is a real value on both ends: salience 0 and strength 0 stay 0.
1306
- const zero = parseFrontmatter('---\nsalience: 0\nstrength: 0\nrevisions: 0\n---\nBODY');
1307
- assert(zero.fields.salience === 0 && zero.fields.strength === 0 && zero.fields.revisions === 0, 'zero was mistaken for empty');
1308
-
1309
- const top = parseFrontmatter('---\nsalience: 9\nstrength: -4\nrevisions: 1.9\n---\nBODY');
1310
- assert(top.fields.salience === 9 && top.fields.strength === 0 && top.fields.revisions === 1, 'valid top salience / still-clamped strength broke');
1311
- expectCode(() => parseFrontmatter('---\nsalience: 10\n---\nBODY'), 'E_CARD_SALIENCE_INVALID');
1312
- expectCode(() => parseFrontmatter('---\nsalience: 1.5\n---\nBODY'), 'E_CARD_SALIENCE_INVALID');
1313
- return 'empty numerics default to 3/3/0; salience 0/9 valid, junk/10/1.5 coded';
1314
- });
1315
- }
1316
-
1317
- function m16ToolArgGuards(runCase, assert, TMP) {
1318
- runCase('M16', 'memory_* tool entries reject null/non-plain args and config with E_BAD_ARGUMENT', () => {
1319
- const root = fixture(TMP, 'm16-args');
1320
- put(root, 'topics/valid.md', cardFile({ title: '有效卡', strength: 2, lastUsed: FIXED_DAY }));
1321
- const byName = new Map(TOOLS.map((tool) => [tool.name, tool]));
1322
- const names = ['memory_index', 'memory_consolidate', 'memory_reinforce', 'memory_forget', 'memory_procedure', 'memory_recall'];
1323
- const validArgs = {
1324
- memory_index: {},
1325
- memory_consolidate: {},
1326
- memory_reinforce: { path: 'topics/valid.md' },
1327
- memory_forget: {},
1328
- memory_procedure: { action: 'list' },
1329
- memory_recall: { query: '有效' },
1330
- };
1331
- const before = snapshot(root);
1332
- const badArgs = [null, undefined, 'text', [1, 2], 42, new Date(0)];
1333
- for (const name of names) {
1334
- for (const args of badArgs) {
1335
- assertCodedError(
1336
- assert,
1337
- invokeTool(byName.get(name), args, { memoryRoot: root }),
1338
- 'E_BAD_ARGUMENT',
1339
- `${name} args=${Object.prototype.toString.call(args)}`,
1340
- );
1341
- }
1342
- }
1343
- const badConfigs = [null, undefined, 'cfg', [], 7, new Date(0)];
1344
- for (const name of names) {
1345
- for (const config of badConfigs) {
1346
- assertCodedError(
1347
- assert,
1348
- invokeTool(byName.get(name), validArgs[name], config),
1349
- 'E_BAD_ARGUMENT',
1350
- `${name} config=${Object.prototype.toString.call(config)}`,
1351
- );
1352
- }
1353
- }
1354
- sameSnapshot(assert, before, snapshot(root), 'rejected memory tool calls wrote files');
1355
-
1356
- const dry = invokeTool(byName.get('memory_index'), {}, { memoryRoot: root });
1357
- assert(dry.ok === true && dry.value.dryRun === true, 'the guard rejected a valid plain-object call');
1358
- return `${names.length} tools reject ${badArgs.length} bad args and ${badConfigs.length} bad configs without writes`;
1359
- });
1360
- }
1361
-
1362
- function m17MultilineValues(runCase, assert, expectCode) {
1363
- runCase('M17', 'multiline frontmatter values are refused on write and reported on read', () => {
1364
- expectCode(() => serializeFrontmatter({ ...DEFAULT_FIELDS, title: '第一行\n第二行' }, {}), 'E_BAD_ARGUMENT');
1365
- expectCode(() => serializeFrontmatter({ ...DEFAULT_FIELDS, tags: ['a', 'b\nc'] }, {}), 'E_BAD_ARGUMENT');
1366
- expectCode(() => serializeFrontmatter({}, { custom: 'x\r\ny' }), 'E_BAD_ARGUMENT');
1367
- expectCode(() => updateFrontmatter('---\ntitle: x\n---\nBODY', { title: 'a\r\nb' }), 'E_BAD_ARGUMENT');
1368
- expectCode(() => updateFrontmatter('---\ntitle: x\n---\nBODY', { tags: ['ok', 'bad\nline'] }), 'E_BAD_ARGUMENT');
1369
-
1370
- expectCode(() => parseFrontmatter('---\ntitle: 第一行\n 第二行\n---\nBODY'), 'E_FRONTMATTER_MULTILINE');
1371
- expectCode(() => parseFrontmatter('---\ntitle: 第一行\n\t第二行\n---\nBODY'), 'E_FRONTMATTER_MULTILINE');
1372
-
1373
- // Indented blank lines and indented comments are not continuation values.
1374
- const legal = parseFrontmatter('---\ntitle: x\n \n # 注释\n---\nBODY');
1375
- assert(legal.fields.title === 'x' && legal.body === 'BODY', 'blank/comment lines were mistaken for a multiline value');
1376
- return 'write side refuses CR/LF; parse side throws E_FRONTMATTER_MULTILINE instead of dropping lines';
1377
- });
1378
- }
1379
-
1380
- // M18 is split in two on purpose. A directory junction is creatable without
1381
- // any privilege, so the junction assertions must ALWAYS run. A file symlink is
1382
- // not: a plain Windows user gets EPERM, and then the fixture itself cannot
1383
- // exist. That half is registered through runSymlinkCase, which reports SKIP
1384
- // with the OS error instead of a fake PASS or a bogus failure.
1385
- function m18RealpathGuard(runCase, assert, expectCode, TMP) {
1386
- runCase('M18', 'memory paths: junction escape refused by realpath; missing tails and legal paths still resolve', () => {
1387
- const base = path.join(TMP, 'memory-cases', 'm18');
1388
- const root = path.join(base, 'root');
1389
- const outside = path.join(base, 'outside');
1390
- fs.mkdirSync(root, { recursive: true });
1391
- fs.mkdirSync(outside, { recursive: true });
1392
- fs.writeFileSync(path.join(outside, 'secret.md'), '# outside\n');
1393
-
1394
- assert(
1395
- resolveInside(root, path.join('topics', 'new.md')) === path.join(root, 'topics', 'new.md'),
1396
- 'a legal, not-yet-created path resolves unchanged',
1397
- );
1398
-
1399
- const junction = path.join(root, 'j-link');
1400
- fs.symlinkSync(outside, junction, 'junction');
1401
- const dirEscape = expectCode(
1402
- () => resolveInside(root, path.join('j-link', 'secret.md')),
1403
- 'E_PATH_OUTSIDE_ROOT',
1404
- );
1405
- assert(dirEscape.message.includes('真实路径'), 'junction refusal must name the real path');
1406
- expectCode(
1407
- () => resolveInside(root, path.join('j-link', 'missing', 'new.md')),
1408
- 'E_PATH_OUTSIDE_ROOT',
1409
- );
1410
- return 'junction dir and junction tail refused; legal missing path still resolves';
1411
- });
1412
- }
1413
-
1414
- function m18SymlinkGuard(runSymlinkCase, assert, expectCode, TMP) {
1415
- runSymlinkCase('M18-link', 'memory paths: file symlink escape refused by realpath; linked card refused (needs symlink privilege)', () => {
1416
- const base = path.join(TMP, 'memory-cases', 'm18-link');
1417
- const root = path.join(base, 'root');
1418
- const outside = path.join(base, 'outside');
1419
- fs.mkdirSync(root, { recursive: true });
1420
- fs.mkdirSync(outside, { recursive: true });
1421
- fs.writeFileSync(path.join(outside, 'secret.md'), '# outside\n');
1422
-
1423
- const fileLink = path.join(root, 'topics-link.md');
1424
- fs.symlinkSync(path.join(outside, 'secret.md'), fileLink, 'file');
1425
- expectCode(() => resolveInside(root, 'topics-link.md'), 'E_PATH_OUTSIDE_ROOT');
1426
-
1427
- const memoryRoot = path.join(base, 'memory');
1428
- fs.mkdirSync(path.join(memoryRoot, 'topics'), { recursive: true });
1429
- fs.symlinkSync(path.join(outside, 'secret.md'), path.join(memoryRoot, 'topics', 'evil.md'), 'file');
1430
- expectCode(
1431
- () => memoryReinforce({ path: 'topics/evil.md' }, { memoryRoot }),
1432
- 'E_PATH_OUTSIDE_ROOT',
1433
- );
1434
- return 'file symlink and linked card both refused with E_PATH_OUTSIDE_ROOT';
1435
- });
1436
- }
1437
-
1438
- /**
1439
- * Two consolidations inside one millisecond used to compute the same backup
1440
- * name, so the second copy silently overwrote the first. The name must gain a
1441
- * numeric suffix instead; this test pins that by running the same instant twice.
1442
- */
1443
- function m19ReportBackupSameMs(runCase, assert, TMP) {
1444
- runCase('M19', 'same-millisecond report backups keep both copies (suffix -2)', () => {
1445
- const root = fixture(TMP, 'm19-report-backup');
1446
- put(root, 'topics/备份策略.md', [
1447
- '---',
1448
- 'title: 备份策略',
1449
- 'tags: [backup]',
1450
- 'salience: 3',
1451
- 'strength: 3',
1452
- 'last_used: 2026-11-01',
1453
- 'revisions: 1',
1454
- '---',
1455
- '# 备份策略',
1456
- '',
1457
- '报告覆盖前必须先备份。',
1458
- '',
1459
- ].join('\n'));
1460
- put(root, 'journal/2026-11-02.md', [
1461
- '---',
1462
- 'date: 2026-11-02',
1463
- '---',
1464
- '## 记录',
1465
- '',
1466
- '决定:同一毫秒的两次巩固也要留下两份报告备份。',
1467
- '',
1468
- ].join('\n'));
1469
-
1470
- const now = new Date('2026-11-02T03:00:00.000Z');
1471
- const options = { memoryRoot: root, now, today: FIXED_DAY };
1472
- const first = memoryConsolidate({}, options);
1473
- assert(first.ok === true && fs.existsSync(first.reportPath), 'first run must write the report');
1474
- const firstText = fs.readFileSync(first.reportPath, 'utf8');
1475
-
1476
- const second = memoryConsolidate({}, options);
1477
- const third = memoryConsolidate({}, options);
1478
- assert(second.ok === true && third.ok === true, 'repeat runs must still succeed');
1479
-
1480
- const dir = path.join(root, '.longlive', 'reports', '.backups');
1481
- const names = fs.readdirSync(dir).filter((name) => name.endsWith('.md')).sort();
1482
- assert(names.length === 2, `two same-ms backups expected, got ${names.length}: ${names.join(', ')}`);
1483
- const baseName = names.find((name) => !/-\d+\.md$/.test(name));
1484
- assert(Boolean(baseName), `base backup name missing: ${names.join(', ')}`);
1485
- const suffixName = names.find((name) => name !== baseName);
1486
- assert(
1487
- suffixName === `${baseName.replace(/\.md$/, '')}-2.md`,
1488
- `collision suffix must be -2, got ${suffixName}`,
1489
- );
1490
- assert(
1491
- fs.readFileSync(path.join(dir, baseName), 'utf8') === firstText,
1492
- 'the first backup must preserve the first report',
1493
- );
1494
- return `same-ms backups: ${names.join(' + ')}; first copy preserved`;
1495
- });
1496
- }
1497
-
1498
- function m20AffectMarkers(runCase, assert, TMP) {
1499
- runCase('M20', 'affect markers: 3 marked win, bad format/range listed in badAffect, no throw', () => {
1500
- const root = fixture(TMP, 'm20-affect');
1501
- put(root, 'topics/工程约定.md', [
1502
- '---',
1503
- 'title: 工程约定',
1504
- 'tags: [engineering]',
1505
- 'salience: 3',
1506
- 'strength: 3',
1507
- 'last_used: 2026-10-01',
1508
- 'revisions: 1',
1509
- '---',
1510
- '# 工程约定',
1511
- '',
1512
- '所有写入先备份。',
1513
- '',
1514
- ].join('\n'));
1515
- put(root, 'journal/2026-11-01.md', [
1516
- '# 记录',
1517
- '',
1518
- '第一段:今天普通地过完了。<!-- affect: 5 | 其实心里很重 -->',
1519
- '',
1520
- '第二段:随手记一下。<!-- affect: 2 | 只是流水账 -->',
1521
- '',
1522
- '第三段:决定按方案走。<!-- affect: 1 | 其实很不安 -->',
1523
- '',
1524
- '第四段:坏格式的情绪标记。<!-- affect: high | 不知道填什么 -->',
1525
- '',
1526
- '第五段:越界的情绪标记。<!-- affect: 9 | 情绪过头 -->',
1527
- '',
1528
- '第六段:决定明天继续。',
1529
- '',
1530
- ].join('\n'));
1531
-
1532
- const config = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
1533
- const result = memoryConsolidate({}, config);
1534
- assert(result.ok === true, 'consolidate should succeed despite bad markers');
1535
- assert(result.entries === 6, `expected 6 paragraph entries, got ${result.entries}`);
1536
- assert(result.proposals.length === 6, `expected 6 proposals, got ${result.proposals.length}`);
1537
-
1538
- const proposals = result.proposals;
1539
- const marked = proposals.slice(0, 3);
1540
- assert(marked.map((item) => item.suggestedSalience).join(',') === '5,2,1', `marked salience: ${marked.map((item) => item.suggestedSalience).join(',')}`);
1541
- assert(marked.every((item) => item.salienceSource === 'marked'), `marked sources: ${marked.map((item) => item.salienceSource).join(',')}`);
1542
- assert(marked[0].affectReason === '其实心里很重' && marked[2].affectReason === '其实很不安', 'affect reason was not carried into the proposal');
1543
-
1544
- // The two broken markers must not throw and must not win over the regex.
1545
- const broken = proposals.slice(3, 5);
1546
- assert(broken.every((item) => item.salienceSource !== 'marked'), `broken markers were trusted: ${broken.map((item) => item.salienceSource).join(',')}`);
1547
- assert(proposals[5].suggestedSalience === 4 && proposals[5].salienceSource === 'heuristic', `unmarked paragraph: ${proposals[5].suggestedSalience}/${proposals[5].salienceSource}`);
1548
-
1549
- assert(Array.isArray(result.badAffect) && result.badAffect.length === 2, `badAffect: ${JSON.stringify(result.badAffect)}`);
1550
- assert(result.badAffect.some((item) => item.code === 'E_AFFECT_FORMAT'), 'bad format was not recorded');
1551
- assert(result.badAffect.some((item) => item.code === 'E_AFFECT_RANGE'), 'out-of-range marker was not recorded');
1552
- assert(result.badAffect.every((item) => item.source === 'journal/2026-11-01.md' && item.marker.includes('affect')), 'badAffect entries lack source/marker');
1553
-
1554
- const report = fs.readFileSync(result.reportPath, 'utf8');
1555
- assert(report.includes('salienceSource'), 'report does not expose salienceSource');
1556
- assert(report.includes('badAffect'), 'report does not expose badAffect');
1557
- assert(report.includes('affect: 9') && report.includes('E_AFFECT_RANGE'), 'report does not name the out-of-range marker');
1558
- assert(proposals.every((item) => item.excerpt.length > 0 && !item.excerpt.includes('<!--')), 'marker leaked into an excerpt');
1559
- return '5/2/1 marked values trusted; high and 9 recorded in badAffect; unmarked fell back to heuristic 4';
1560
- });
1561
- }
1562
-
1563
- function m21DedupSince(runCase, assert, TMP) {
1564
- runCase('M21', 'proposal ids: repeat run 0 new ids; since advances only when every proposal is resolved', () => {
1565
- const root = fixture(TMP, 'm21-dedup');
1566
- put(root, 'topics/工程约定.md', [
1567
- '---',
1568
- 'title: 工程约定',
1569
- 'tags: [engineering]',
1570
- 'salience: 3',
1571
- 'strength: 3',
1572
- 'last_used: 2026-10-01',
1573
- 'revisions: 1',
1574
- '---',
1575
- '# 工程约定',
1576
- '',
1577
- '所有写入先备份。',
1578
- '',
1579
- ].join('\n'));
1580
- const paragraphs = [
1581
- '决定:所有写入先备份。',
1582
- '随手记:周三买牛奶。<!-- affect: 2 | 琐事 -->',
1583
- '铁律:永远不许骗他。',
1584
- ];
1585
- put(root, 'journal/2026-11-01.md', ['# 记录', '', ...paragraphs.flatMap((text) => [text, '']), ''].join('\n'));
1586
- put(root, '.longlive/memory-state.json', `${JSON.stringify({
1587
- lastConsolidated: '2026-10-20',
1588
- indexBuiltAt: null,
1589
- forgetRuns: [],
1590
- }, null, 2)}\n`);
1591
-
1592
- const stateAbs = path.join(root, '.longlive', 'memory-state.json');
1593
- const readStateFile = () => JSON.parse(fs.readFileSync(stateAbs, 'utf8'));
1594
- const config = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
1595
-
1596
- const first = memoryConsolidate({}, config);
1597
- assert(first.proposals.length === 3, `first run proposals: ${first.proposals.length}`);
1598
- assert(first.stateUpdated === false, 'first run must not advance lastConsolidated');
1599
- assert(first.duplicatesSuppressed === 0, `first run duplicates: ${first.duplicatesSuppressed}`);
1600
- const firstState = readStateFile();
1601
- assert(firstState.lastConsolidated === '2026-10-20', `lastConsolidated moved on the first run: ${firstState.lastConsolidated}`);
1602
- assert(Array.isArray(firstState.seenProposals) && firstState.seenProposals.length === 3, 'seenProposals was not persisted');
1603
- assert(firstState.seenProposals.every((entry) => entry.status === 'proposed' && entry.day === '2026-11-01'), 'seenProposals status/day is wrong');
1604
-
1605
- const expectedId = (source, text) => crypto.createHash('sha1')
1606
- .update(`${source}\n${text.replace(/\s+/g, ' ').trim()}`, 'utf8').digest('hex').slice(0, 12);
1607
- assert(first.proposals[0].id === expectedId('journal/2026-11-01.md', paragraphs[0]), `proposal id formula: ${first.proposals[0].id}`);
1608
- assert(first.proposals[1].id === expectedId('journal/2026-11-01.md', paragraphs[1].replace(/\s*<!--[\s\S]*?-->\s*/g, '')), `marked proposal id: ${first.proposals[1].id}`);
1609
- assert(first.proposals.every((item) => /^[0-9a-f]{12}$/.test(item.id)), 'proposal ids are not 12 hex chars');
1610
-
1611
- const second = memoryConsolidate({}, config);
1612
- assert(second.proposals.length === 0, `repeat run re-proposed ${second.proposals.length} paragraph(s)`);
1613
- assert(second.duplicatesSuppressed === 3, `duplicatesSuppressed: ${second.duplicatesSuppressed}`);
1614
- assert(second.stateUpdated === false, 'repeat run must not advance lastConsolidated');
1615
- const secondState = readStateFile();
1616
- assert(secondState.lastConsolidated === '2026-10-20', 'unresolved proposals advanced lastConsolidated');
1617
- assert(secondState.seenProposals.length === 3, 'repeat run duplicated seenProposals entries');
1618
- const repeatRate = second.proposals.filter((item) => first.proposals.some((old) => old.id === item.id)).length
1619
- / Math.max(1, second.proposals.length + second.duplicatesSuppressed);
1620
- assert(repeatRate === 0, `duplicate proposal rate is ${repeatRate}`);
1621
-
1622
- // Only an accepted/rejected status resolves a proposal. Flip them by hand:
1623
- // this slice has no merge tool yet, so the state file is the contract.
1624
- const resolved = readStateFile();
1625
- for (const entry of resolved.seenProposals) entry.status = 'accepted';
1626
- fs.writeFileSync(stateAbs, `${JSON.stringify(resolved, null, 2)}\n`, 'utf8');
1627
-
1628
- const third = memoryConsolidate({}, config);
1629
- assert(third.proposals.length === 0, `resolved run re-proposed ${third.proposals.length} paragraph(s)`);
1630
- assert(third.stateUpdated === true, 'all-resolved run did not advance lastConsolidated');
1631
- assert(third.lastConsolidated === '2026-11-01', `advanced to ${third.lastConsolidated}, expected 2026-11-01`);
1632
- assert(readStateFile().lastConsolidated === '2026-11-01', 'advanced day was not persisted');
1633
-
1634
- const fourth = memoryConsolidate({}, config);
1635
- assert(fourth.since === '2026-11-01', `since after advance: ${fourth.since}`);
1636
- assert(fourth.proposals.length === 0 && fourth.stateUpdated === false, 'post-advance run should be a no-op');
1637
- return '3 ids stable across two runs (0 re-proposed); manual accept advanced lastConsolidated 2026-10-20 -> 2026-11-01';
1638
- });
1639
- }
1640
-
1641
- function m22ForgetRules(runCase, assert, TMP) {
1642
- runCase('M22', 'forget rules: 89/90/91 and 364/365/366, salience-5 immunity, dry-run writes nothing', () => {
1643
- const root = fixture(TMP, 'm22-rules');
1644
- const cards = {
1645
- 'topics/d89.md': cardFile({ title: '八十九天', strength: 1, lastUsed: '2026-08-05' }),
1646
- 'topics/d90low.md': cardFile({ title: '九十天低强度', strength: 2, lastUsed: '2026-08-04' }),
1647
- 'topics/d90high.md': cardFile({ title: '九十天高强度', strength: 9, lastUsed: '2026-08-04' }),
1648
- 'topics/d91low.md': cardFile({ title: '九十一天低强度', strength: 2, lastUsed: '2026-08-03' }),
1649
- 'topics/d91high.md': cardFile({ title: '九十一天高强度', strength: 3, lastUsed: '2026-08-03' }),
1650
- 'topics/d364.md': cardFile({ title: '三百六十四天', strength: 9, lastUsed: '2025-11-03' }),
1651
- 'topics/d365low.md': cardFile({ title: '三百六十五天低强度', strength: 2, lastUsed: '2025-11-02' }),
1652
- 'topics/d365high.md': cardFile({ title: '三百六十五天高强度', strength: 9, lastUsed: '2025-11-02' }),
1653
- 'topics/d366.md': cardFile({ title: '三百六十六天', strength: 9, lastUsed: '2025-11-01', tags: ['项目甲', '老张'] }),
1654
- 'topics/core5.md': cardFile({ title: '核心五', salience: 5, strength: 0, lastUsed: '2015-01-01' }),
1655
- 'topics/fresh.md': cardFile({ title: '新鲜', strength: 1, lastUsed: '2026-11-01' }),
1656
- };
1657
- for (const [rel, text] of Object.entries(cards)) put(root, rel, text);
1658
- const config = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
1659
- const before = snapshot(root);
1660
-
1661
- const dry = memoryForget({}, config);
1662
- assert(dry.status === 'dry-run' && dry.dryRun === true && dry.applied === false, 'forget default must be a dry-run');
1663
- const picked = dry.proposals.map((item) => `${item.path}:${item.kind}:${item.ageDays}`).join(',');
1664
- assert(picked === 'topics/d366.md:tombstone:366,topics/d365low.md:epochs:365,topics/d91low.md:epochs:91', `decay rules picked ${picked}`);
1665
- assert(!dry.proposals.some((item) => ['topics/d89.md', 'topics/d90low.md', 'topics/d90high.md', 'topics/d91high.md', 'topics/d364.md', 'topics/d365high.md', 'topics/fresh.md'].includes(item.path)), 'a below-threshold card was proposed');
1666
- assert(dry.immune.length === 1 && dry.immune[0].path === 'topics/core5.md', 'salience-5 card is not immune');
1667
- assert(dry.undated.length === 0, `unexpected undated cards: ${dry.undated.length}`);
1668
- assert(dry.rules.immuneSalience === 5, `immune threshold ${dry.rules.immuneSalience}`);
1669
- assert(dry.rules.epochs.minAgeDays === 90 && dry.rules.epochs.maxStrength === 2 && dry.rules.epochs.strict === true, 'epochs rule is not >90d with strength<=2');
1670
- assert(dry.rules.tombstone.minAgeDays === 365 && dry.rules.tombstone.strict === true, 'tombstone rule is not >365d');
1671
-
1672
- for (const item of dry.proposals) {
1673
- assert(item.epitaph && typeof item.epitaph.reason === 'string' && item.epitaph.reason.length > 0, `${item.path}: epitaph.reason missing`);
1674
- assert(typeof item.epitaph.affects === 'string' && item.epitaph.affects.length > 0, `${item.path}: epitaph.affects missing`);
1675
- assert(typeof item.epitaph.stillUseful === 'string' && item.epitaph.stillUseful.includes('移回'), `${item.path}: epitaph.stillUseful is not a recovery note`);
1676
- assert(typeof item.destRel === 'string' && item.destRel.endsWith(item.path), `${item.path}: destRel ${item.destRel}`);
1677
- }
1678
- assert(dry.proposals[0].epitaph.affects.includes('项目甲'), 'tags are not carried into affects');
1679
- assert(dry.proposals[1].epitaph.affects.includes('未标注'), 'an untagged card should say its impact is unknown');
1680
-
1681
- assert(dry.scaffold.ready === false, 'a fresh root must not report a ready scaffold');
1682
- assert(dry.scaffold.missing.join(',') === 'epochs,archive,backups,audit', `scaffold missing: ${dry.scaffold.missing.join(',')}`);
1683
- assert(dry.scaffold.entries.every((entry) => entry.exists === false), 'scaffold entry reported as existing');
1684
- assert(dry.audit.appended === 0 && dry.audit.willAppend === 1, 'dry-run audit plan is wrong');
1685
-
1686
- for (const rel of ['epochs', 'archive', 'archive/tombstones', '.longlive']) {
1687
- assert(!fs.existsSync(path.join(root, rel)), `dry-run created ${rel}`);
1688
- }
1689
- assert(!fs.existsSync(path.join(root, 'tombstones.md')), 'dry-run created tombstones.md');
1690
- sameSnapshot(assert, before, snapshot(root), 'forget rules dry-run');
1691
- assert(dry.message.includes('未写任何文件'), 'dry-run message should say nothing was written');
1692
- return '89/90/91 and 364/365/366 all decided by strict >N rules; salience 5 immune; dry-run created no file and no directory';
1693
- });
1694
- }
1695
-
1696
- function m23ForgetExecute(runCase, assert, TMP) {
1697
- runCase('M23', 'forget confirm: scaffold created, per-card backups, three-question epitaphs, one audit line', () => {
1698
- const root = fixture(TMP, 'm23-execute');
1699
- const cards = {
1700
- 'topics/e91a.md': cardFile({ title: '九十一天甲', strength: 1, lastUsed: '2026-08-03' }),
1701
- 'topics/e91b.md': cardFile({ title: '九十一天乙', strength: 2, lastUsed: '2026-08-03', tags: ['项目甲', '老张'] }),
1702
- 'topics/t366.md': cardFile({ title: '超过一年', strength: 4, lastUsed: '2025-11-01', tags: ['旧项目'] }),
1703
- 'topics/core5.md': cardFile({ title: '核心五', salience: 5, strength: 0, lastUsed: '2015-01-01' }),
1704
- 'topics/fresh.md': cardFile({ title: '新鲜', strength: 1, lastUsed: '2026-11-01' }),
1705
- };
1706
- const originals = new Map();
1707
- for (const [rel, text] of Object.entries(cards)) {
1708
- put(root, rel, text);
1709
- originals.set(rel, text);
1710
- }
1711
- const dests = {
1712
- 'topics/e91a.md': 'epochs/topics/e91a.md',
1713
- 'topics/e91b.md': 'epochs/topics/e91b.md',
1714
- 'topics/t366.md': 'archive/tombstones/topics/t366.md',
1715
- };
1716
- const config = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
1717
- const before = snapshot(root);
1718
-
1719
- const done = memoryForget({ confirm: 'FORGET' }, config);
1720
- assert(done.applied === true && done.moved === 3, `applied=${done.applied} moved=${done.moved}`);
1721
- assert(done.scaffold.ready === true, 'scaffold is not ready after an executed run');
1722
- assert(done.scaffold.created.join(',') === 'epochs,archive,backups', `scaffold created: ${done.scaffold.created.join(',')}`);
1723
- for (const rel of ['epochs', 'archive/tombstones', '.longlive/backups']) {
1724
- assert(fs.statSync(path.join(root, rel)).isDirectory(), `${rel} is not a directory after confirm`);
1725
- }
1726
-
1727
- for (const [from, to] of Object.entries(dests)) {
1728
- assert(!fs.existsSync(path.join(root, from)), `source still present: ${from}`);
1729
- assert(fs.existsSync(path.join(root, to)), `destination missing: ${to}`);
1730
- assert(fs.readFileSync(path.join(root, to), 'utf8') === originals.get(from), `bytes changed after the move: ${to}`);
1731
- }
1732
- assert(fs.readFileSync(path.join(root, 'topics/core5.md'), 'utf8') === originals.get('topics/core5.md'), 'salience-5 card was touched');
1733
- assert(fs.readFileSync(path.join(root, 'topics/fresh.md'), 'utf8') === originals.get('topics/fresh.md'), 'fresh card was touched');
1734
-
1735
- const tombText = fs.readFileSync(path.join(root, 'tombstones.md'), 'utf8');
1736
- const lines = tombText.split(/\r?\n/).filter((line) => line.startsWith('- '));
1737
- assert(lines.length === 3, `tombstones.md has ${lines.length} lines, expected 3`);
1738
- for (const line of lines) {
1739
- for (const field of ['reason:', 'affects:', 'stillUseful:', 'recoverable:']) {
1740
- assert(line.includes(field), `tombstone line lacks ${field}: ${line}`);
1741
- }
1742
- }
1743
- assert(lines.some((line) => line.includes('affects: tags: 项目甲')), 'a tagged card does not name its tags under affects');
1744
- assert(lines.some((line) => line.includes('topics/t366.md') && line.includes('recoverable: archive/tombstones/topics/t366.md')), 'tombstoned card line is wrong');
1745
- assert(!lines.some((line) => line.includes('core5.md')), 'immune card got a tombstone line');
1746
- assert(done.tombstoneLines === 3, `result tombstoneLines=${done.tombstoneLines}`);
1747
-
1748
- const backups = backupNames(root);
1749
- for (const [from] of Object.entries(dests)) {
1750
- const base = path.basename(from, '.md');
1751
- const names = backups.filter((name) => name.startsWith(`${base}.`));
1752
- assert(names.length === 1, `${from} should have exactly one backup, got ${names.join(',') || 'none'}`);
1753
- assert(fs.readFileSync(path.join(root, '.longlive', 'backups', names[0]), 'utf8') === originals.get(from), `backup bytes changed: ${from}`);
1754
- }
1755
- assert(done.archived.length === 3 && done.archived.every((item) => item.backupRel && fs.existsSync(path.join(root, item.backupRel))), 'result does not point at the per-card backups');
1756
-
1757
- const audit = fs.readFileSync(path.join(root, '.longlive', 'audit.log'), 'utf8').trim().split(/\r?\n/);
1758
- assert(audit.length === 1 && audit[0].includes('memory_forget') && audit[0].includes('moved=3'), `audit.log: ${audit.join(' | ')}`);
1759
- assert(done.audit.appended === 1, 'result audit count is wrong');
1760
- const state = JSON.parse(fs.readFileSync(statePath(root), 'utf8'));
1761
- assert(state.forgetRuns.length === 1 && state.forgetRuns[0].moved === 3, 'forgetRuns was not recorded');
1762
-
1763
- const after = snapshot(root);
1764
- for (const [rel, hash] of before) {
1765
- if (dests[rel]) {
1766
- assert(!after.has(rel), `moved card still at its old path: ${rel}`);
1767
- assert(after.get(dests[rel]) === hash, `moved card changed bytes: ${rel}`);
1768
- continue;
1769
- }
1770
- assert(after.has(rel), `file vanished: ${rel}`);
1771
- assert(after.get(rel) === hash, `untouched file changed: ${rel}`);
1772
- }
1773
- // 3 cards moved (same count, new paths) + 3 per-card backups + tombstones.md
1774
- // + audit.log + memory-state.json = 6 new files, and nothing disappeared.
1775
- assert(after.size === before.size + 6, `file count ${before.size} -> ${after.size}, expected +6 (3 backups + tombstones + audit + state)`);
1776
-
1777
- const beforeSecond = snapshot(root);
1778
- const second = memoryForget({ confirm: 'FORGET' }, config);
1779
- assert(second.status === 'noop' && second.applied === false, `second run status=${second.status}`);
1780
- sameSnapshot(assert, beforeSecond, snapshot(root), 'second confirm run');
1781
- return 'confirm created the three scaffolds, backed up each card, wrote 3 three-question epitaphs, moved 3 cards, kept every byte';
1782
- });
1783
- }
1784
-
1785
- // ---------------------------------------------------------------------------
1786
- // M24-M27: procedural memory (procedures/<slug>.md + memory_procedure).
1787
- //
1788
- // The card format is a contract, not a suggestion: name/trigger/salience/
1789
- // strength/last_used live in the frontmatter, the body is 何时用 / 怎么做
1790
- // (numbered) / 失败模式 / 例子. These cases pin the format through an
1791
- // independent oracle (parseCardForAssertions) and never through the writer
1792
- // that produced it.
1793
- // ---------------------------------------------------------------------------
1794
-
1795
- function procedureTool(assert) {
1796
- const tool = TOOLS.find((item) => item.name === 'memory_procedure');
1797
- assert(Boolean(tool), 'memory_procedure is not registered in TOOLS');
1798
- return tool;
1799
- }
1800
-
1801
- function procedureOptions(root) {
1802
- return { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
1803
- }
1804
-
1805
- /** A plain topics/ card, for index fixtures that need a non-procedure row. */
1806
- function topicCard(title, salience = 3, strength = 3, lastUsed = FIXED_DAY) {
1807
- return [
1808
- '---',
1809
- `title: ${title}`,
1810
- 'date: 2026-11-02',
1811
- 'tags: [procedural]',
1812
- `salience: ${salience}`,
1813
- `strength: ${strength}`,
1814
- `last_used: ${lastUsed}`,
1815
- 'revisions: 0',
1816
- '---',
1817
- `# ${title}`,
1818
- '',
1819
- '正文。',
1820
- '',
1821
- ].join('\n');
1822
- }
1823
-
1824
- function m24ProcedureSaveGetList(runCase, assert, TMP) {
1825
- runCase('M24', 'memory_procedure: save/get/list 与同名合并(版本号 +1,旧版进备份)', () => {
1826
- const root = fixture(TMP, 'm24-procedure');
1827
- const tool = procedureTool(assert);
1828
- const options = procedureOptions(root);
1829
-
1830
- const created = invokeTool(tool, {
1831
- action: 'save',
1832
- name: '部署流程',
1833
- trigger: ['部署', '上线'],
1834
- when: '要把新版本发到线上时。',
1835
- steps: ['跑全量测试', '打包并校验 sha256'],
1836
- failureModes: '测试红了就别发。',
1837
- example: '0.7.7 发版走的就是这条。',
1838
- }, options);
1839
- assert(created.ok === true, `save failed: ${created.error?.message}`);
1840
- const card = created.value;
1841
- assert(card.action === 'save' && card.status === 'created', `save status: ${card.status}`);
1842
- assert(card.version === 1 && card.revisions === 0, `new card version/revisions: ${card.version}/${card.revisions}`);
1843
- assert(card.slug === '部署流程' && card.rel === 'procedures/部署流程.md', `slug/rel: ${card.slug} / ${card.rel}`);
1844
-
1845
- const abs = path.join(root, 'procedures', '部署流程.md');
1846
- assert(fs.existsSync(abs), 'the procedure card was not written');
1847
- const firstText = fs.readFileSync(abs, 'utf8');
1848
- assert(card.sha256 === sha(firstText), 'save sha256 does not match the file on disk');
1849
-
1850
- // Independent oracle: the key lines themselves, in the documented order.
1851
- const parsed = parseCardForAssertions(firstText);
1852
- const keys = firstText.split('\n').slice(1, 7).map((line) => line.slice(0, line.indexOf(':')));
1853
- assert(
1854
- keys.join(',') === 'name,trigger,salience,strength,last_used,revisions',
1855
- `frontmatter must be name,trigger,salience,strength,last_used,revisions — got ${keys.join(',')}`,
1856
- );
1857
- assert(parsed.fields.name === '部署流程', `frontmatter name: ${parsed.fields.name}`);
1858
- assert(parsed.fields.trigger === '[部署, 上线]', `frontmatter trigger: ${parsed.fields.trigger}`);
1859
- assert(parsed.fields.salience === '3' && parsed.fields.strength === '3', `defaults: ${parsed.fields.salience}/${parsed.fields.strength}`);
1860
- assert(parsed.fields.last_used === FIXED_DAY, `last_used: ${parsed.fields.last_used}`);
1861
- assert(parsed.fields.revisions === '0', `revisions: ${parsed.fields.revisions}`);
1862
- for (const [heading, needle] of [
1863
- ['何时用', '要把新版本发到线上时'],
1864
- ['怎么做', '1. 跑全量测试'],
1865
- ['失败模式', '测试红了就别发'],
1866
- ['例子', '0.7.7 发版走的就是这条'],
1867
- ]) {
1868
- assert(parsed.body.includes(`## ${heading}`), `missing section 「${heading}」`);
1869
- assert(parsed.body.includes(needle), `section 「${heading}」 lost its content: ${needle}`);
1870
- }
1871
- assert(parsed.body.includes('2. 打包并校验 sha256'), 'steps are not numbered');
1872
-
1873
- const readOnlyBefore = snapshot(root);
1874
- const got = invokeTool(tool, { action: 'get', slug: '部署流程' }, options);
1875
- assert(got.ok === true, `get failed: ${got.error?.message}`);
1876
- assert(got.value.name === '部署流程' && got.value.rel === card.rel, `get name/rel: ${got.value.name} / ${got.value.rel}`);
1877
- assert(JSON.stringify(got.value.trigger) === JSON.stringify(['部署', '上线']), `get trigger: ${JSON.stringify(got.value.trigger)}`);
1878
- assert(
1879
- JSON.stringify(got.value.sections.steps) === JSON.stringify(['跑全量测试', '打包并校验 sha256']),
1880
- `get steps: ${JSON.stringify(got.value.sections.steps)}`,
1881
- );
1882
- assert(got.value.sections.when.includes('要把新版本发到线上时'), 'get lost the 「何时用」 text');
1883
- assert(got.value.text === firstText, 'get did not return the raw card text');
1884
- assert(got.value.version === 1, `get version: ${got.value.version}`);
1885
-
1886
- const listed = invokeTool(tool, { action: 'list' }, options);
1887
- assert(listed.ok === true && listed.value.count === 1, `list count: ${listed.value?.count}`);
1888
- assert(listed.value.items[0].rel === card.rel && listed.value.items[0].version === 1, 'list entry is wrong');
1889
- sameSnapshot(assert, readOnlyBefore, snapshot(root), 'get/list must not write');
1890
-
1891
- // Same slug again: revisions + 1, previous bytes go to .longlive/backups.
1892
- const merged = invokeTool(tool, {
1893
- action: 'save',
1894
- name: '部署流程',
1895
- steps: ['跑全量测试', '打包并校验 sha256', '灰度 10%'],
1896
- }, options);
1897
- assert(merged.ok === true, `merge save failed: ${merged.error?.message}`);
1898
- assert(
1899
- merged.value.status === 'merged' && merged.value.version === 2 && merged.value.revisions === 1,
1900
- `merged status/version/revisions: ${merged.value.status}/${merged.value.version}/${merged.value.revisions}`,
1901
- );
1902
- assert(
1903
- typeof merged.value.backupRel === 'string' && merged.value.backupRel.startsWith('.longlive/backups/'),
1904
- `backupRel: ${merged.value.backupRel}`,
1905
- );
1906
- assert(fs.readFileSync(path.join(root, merged.value.backupRel), 'utf8') === firstText, 'the backup is not the previous version');
1907
- const secondText = fs.readFileSync(abs, 'utf8');
1908
- const secondParsed = parseCardForAssertions(secondText);
1909
- assert(secondParsed.fields.revisions === '1', `merged revisions: ${secondParsed.fields.revisions}`);
1910
- assert(
1911
- secondParsed.fields.name === '部署流程' && secondParsed.fields.trigger === '[部署, 上线]',
1912
- 'merge dropped name/trigger that were not passed again',
1913
- );
1914
- assert(secondParsed.body.includes('3. 灰度 10%'), 'merge did not write the new step');
1915
- assert(secondParsed.body.includes('要把新版本发到线上时'), 'merge dropped the old 「何时用」');
1916
- assert(secondParsed.body.includes('测试红了就别发'), 'merge dropped the old 「失败模式」');
1917
-
1918
- assertCodedError(assert, invokeTool(tool, { action: 'get', slug: '不存在的卡' }, options), 'E_PROCEDURE_NOT_FOUND', 'get unknown card');
1919
- assertCodedError(assert, invokeTool(tool, { action: 'nope' }, options), 'E_BAD_ARGUMENT', 'unknown action');
1920
- assertCodedError(assert, invokeTool(tool, { action: 'save', name: '新卡' }, options), 'E_BAD_ARGUMENT', 'new card without when/steps');
1921
- assertCodedError(
1922
- assert,
1923
- invokeTool(tool, { action: 'save', name: 'x/y', when: 'a', steps: ['b'] }, options),
1924
- 'E_BAD_ARGUMENT',
1925
- 'name with a path separator',
1926
- );
1927
- assertCodedError(
1928
- assert,
1929
- invokeTool(tool, { action: 'save', name: '空触发', trigger: [], when: 'a', steps: ['b'] }, options),
1930
- 'E_BAD_ARGUMENT',
1931
- 'empty trigger list',
1932
- );
1933
- return 'format pinned by an independent oracle; merge bumped the version and kept the old bytes in backups';
1934
- });
1935
- }
1936
-
1937
- function m25ProcedureTriggerReport(runCase, assert, TMP) {
1938
- runCase('M25', 'memory_procedure: trigger 关键词过滤,report 提权/降权且不动 revisions', () => {
1939
- const root = fixture(TMP, 'm25-procedure');
1940
- const tool = procedureTool(assert);
1941
- const options = procedureOptions(root);
1942
- const save = (args) => {
1943
- const result = invokeTool(tool, { action: 'save', ...args }, options);
1944
- assert(result.ok === true, `save ${args.name} failed: ${result.error?.message}`);
1945
- return result.value;
1946
- };
1947
- const list = (args) => {
1948
- const result = invokeTool(tool, { action: 'list', ...args }, options);
1949
- assert(result.ok === true, `list ${JSON.stringify(args)} failed: ${result.error?.message}`);
1950
- return result.value;
1951
- };
1952
-
1953
- save({ name: '重启服务', trigger: ['运维', '重启'], when: '服务卡死时。', steps: ['systemctl restart 服务'] });
1954
- save({ name: '写周报', trigger: ['文档'], when: '每周五。', steps: ['列三件事'] });
1955
- save({ name: '上线检查', trigger: ['发布', '运维', 'Deploy'], when: '发版前。', steps: ['检查回滚脚本'] });
1956
-
1957
- const ops = list({ trigger: '运维' });
1958
- assert(ops.count === 2, `trigger=运维 should hit 2, got ${ops.count}`);
1959
- assert(ops.items.every((item) => item.trigger.includes('运维')), 'a hit does not carry the keyword');
1960
- assert(
1961
- JSON.stringify(ops.items.map((item) => item.name).sort()) === JSON.stringify(['上线检查', '重启服务'].sort()),
1962
- `hits: ${ops.items.map((item) => item.name).join(',')}`,
1963
- );
1964
- assert(list({ trigger: '运维 重' }).count === 2, 'several keywords must OR together');
1965
- assert(list({ trigger: '重' }).count === 1, `single keyword 重 should hit one card, got ${list({ trigger: '重' }).count}`);
1966
- assert(list({ trigger: 'deploy' }).count === 1, 'trigger matching must be case-insensitive');
1967
- assert(list({ trigger: '不存在' }).count === 0, 'an unknown keyword must hit nothing');
1968
- assert(list({}).count === 3, 'list without a filter returns every card');
1969
- assert(list({ trigger: ' ' }).count === 3, 'a blank filter must not filter anything');
1970
-
1971
- const cardPath = path.join(root, 'procedures', '重启服务.md');
1972
- const before = fs.readFileSync(cardPath, 'utf8');
1973
- const promote = invokeTool(tool, { action: 'report', slug: '重启服务', outcome: 'success', reason: '卡死时用了两次' }, options);
1974
- assert(promote.ok === true, `report success failed: ${promote.error?.message}`);
1975
- assert(promote.value.before.strength === 3 && promote.value.after.strength === 4, `promote: ${JSON.stringify(promote.value.after)}`);
1976
- assert(promote.value.revisions === 0, 'report must not touch revisions (that counts content versions)');
1977
- const after = fs.readFileSync(cardPath, 'utf8');
1978
- const parsedAfter = parseCardForAssertions(after);
1979
- assert(parsedAfter.fields.strength === '4' && parsedAfter.fields.last_used === FIXED_DAY, 'report did not persist strength/last_used');
1980
- assert(parsedAfter.fields.revisions === '0', 'report rewrote revisions');
1981
- assert(parsedAfter.fields.name === '重启服务' && parsedAfter.fields.trigger === '[运维, 重启]', 'report moved name/trigger');
1982
- assert(
1983
- normalizedBody(parsedAfter.body) === normalizedBody(parseCardForAssertions(before).body),
1984
- 'report changed the prose body',
1985
- );
1986
- assert(countRevLines(parsedAfter.body) === 1, `report must append exactly one rev line, got ${countRevLines(parsedAfter.body)}`);
1987
- assert(after.includes(`<!-- rev: ${FIXED_DAY} success`), `rev line: ${after.split('\n').filter((line) => line.includes('rev:')).join(' | ')}`);
1988
-
1989
- const demote = invokeTool(tool, { action: 'report', slug: '重启服务', outcome: 'failure', reason: '重启后又卡了' }, options);
1990
- assert(demote.ok === true && demote.value.after.strength === 3, 'report failure must lower strength');
1991
- assert(countRevLines(parseCardForAssertions(fs.readFileSync(cardPath, 'utf8')).body) === 2, 'the second report did not append a rev line');
1992
-
1993
- let last = null;
1994
- for (let index = 0; index < 7; index += 1) {
1995
- const result = invokeTool(tool, { action: 'report', slug: '写周报', outcome: 'success' }, options);
1996
- assert(result.ok === true, `promote #${index + 1} failed: ${result.error?.message}`);
1997
- last = result.value;
1998
- }
1999
- assert(last.after.strength === 9 && last.capped === true, `strength cap: ${last.after.strength} capped=${last.capped}`);
2000
- assert(list({ trigger: '文档' }).items[0].strength === 9, 'list does not reflect the reported strength');
2001
-
2002
- const floorCard = save({ name: '没用的卡', trigger: ['废弃'], when: '不要用。', steps: ['别做'], strength: 0 });
2003
- assert(floorCard.strength === 0, `explicit strength: ${floorCard.strength}`);
2004
- const floored = invokeTool(tool, { action: 'report', slug: '没用的卡', outcome: 'failure' }, options);
2005
- assert(floored.ok === true && floored.value.after.strength === 0 && floored.value.floored === true, 'strength floor at 0');
2006
-
2007
- const backups = backupNames(root).filter((name) => name.startsWith('重启服务.'));
2008
- assert(backups.length === 2, `two reports need two backups, got ${backups.length}: ${backups.join(', ')}`);
2009
-
2010
- const guarded = snapshot(root);
2011
- assertCodedError(assert, invokeTool(tool, { action: 'report', slug: '写周报' }, options), 'E_BAD_ARGUMENT', 'report without outcome');
2012
- assertCodedError(assert, invokeTool(tool, { action: 'report', outcome: 'success' }, options), 'E_BAD_ARGUMENT', 'report without a target');
2013
- sameSnapshot(assert, guarded, snapshot(root), 'rejected reports wrote files');
2014
- return 'trigger filter ORs and ignores case; report +1/-1 with cap 9 and floor 0, revisions untouched, one backup per report';
2015
- });
2016
- }
2017
-
2018
- function m26IndexProcedures(runCase, assert, TMP) {
2019
- runCase('M26', 'memory_index: procedures/ 进扫描计数,INDEX 有「程序性」小节且不与主表重复', () => {
2020
- const root = fixture(TMP, 'm26-index');
2021
- put(root, 'topics/工程约定.md', topicCard('工程约定'));
2022
- put(root, 'journal/2026-11-02.md', topicCard('今天的日志', 2));
2023
- const saved = invokeTool(procedureTool(assert), {
2024
- action: 'save',
2025
- name: '部署流程',
2026
- trigger: ['部署', '上线'],
2027
- when: '发版时。',
2028
- steps: ['跑测试', '打包'],
2029
- strength: 4,
2030
- }, procedureOptions(root));
2031
- assert(saved.ok === true, `save failed: ${saved.error?.message}`);
2032
-
2033
- const dry = memoryIndex({}, { memoryRoot: root, now: FIXED_NOW });
2034
- assert(dry.ok === true && dry.dryRun === true, 'index dry-run failed');
2035
- assert(dry.cards === 3, `cards: ${dry.cards}, expected 3`);
2036
- assert(dry.procedures === 1, `procedures: ${dry.procedures}, expected 1`);
2037
- assert(dry.legacy === 0, `legacy: ${dry.legacy}`);
2038
- assert(dry.invalid === 0, `invalid: ${dry.invalid}`);
2039
-
2040
- const section = dry.generated;
2041
- assert(section.includes('<!-- cards: 3 · legacy: 0 · procedures: 1 · active: 3 · locked: 0 · archived: 0 -->'), `counter line: ${section.split('\n')[2]}`);
2042
- assert(section.includes('### 程序性(procedures/)'), 'the 「程序性」 subsection heading is missing');
2043
- assert(section.includes('| 名称 | 触发 | 路径 | strength | 版本 |'), 'the procedures table header is missing');
2044
- const procRow = section.split('\n').find((line) => line.includes('procedures/部署流程.md'));
2045
- assert(Boolean(procRow), 'the procedure card is not in the generated section');
2046
- assert(procRow.includes('部署流程'), `procedure row name: ${procRow}`);
2047
- assert(procRow.includes('部署, 上线'), `procedure row trigger: ${procRow}`);
2048
- assert(procRow.includes('| 4 |') && procRow.includes('| 1 |'), `procedure row strength/version: ${procRow}`);
2049
- assert(
2050
- section.split('procedures/部署流程.md').length - 1 === 1,
2051
- 'the procedure card must appear exactly once (not in both tables)',
2052
- );
2053
- assert(countGeneratedRows(section) === 3, `generated rows ${countGeneratedRows(section)} != 3 cards`);
2054
- assert(section.includes('topics/工程约定.md') && section.includes('journal/2026-11-02.md'), 'core rows disappeared');
2055
-
2056
- const before = snapshot(root, ['.longlive', 'INDEX.md']);
2057
- const applied = memoryIndex({ apply: true }, { memoryRoot: root, now: FIXED_NOW });
2058
- assert(applied.ok === true && applied.applied === true, 'index apply failed');
2059
- assert(applied.cards === 3 && applied.procedures === 1, `applied counts: ${applied.cards}/${applied.procedures}`);
2060
- assert(applied.backupRel === null || typeof applied.backupRel === 'string', 'first apply has no backup to report');
2061
- const text = fs.readFileSync(path.join(root, 'INDEX.md'), 'utf8');
2062
- const written = extractGeneratedSection(text);
2063
- assert(Boolean(written), 'INDEX.md has no generated section');
2064
- assert(countGeneratedRows(written) === 3, `INDEX.md rows ${countGeneratedRows(written)} != 3`);
2065
- assert(written.includes('### 程序性(procedures/)'), 'INDEX.md lost the procedures section');
2066
- assert(written.includes('procedures/部署流程.md'), 'INDEX.md lost the procedure row');
2067
- sameSnapshot(assert, before, snapshot(root, ['.longlive', 'INDEX.md']), 'index apply must not touch cards');
2068
-
2069
- const again = memoryIndex({ apply: true }, { memoryRoot: root, now: FIXED_NOW });
2070
- assert(again.changed === false, 'a second apply at the same instant must be a no-op');
2071
- return 'procedures/ scanned and counted, one 「程序性」 table, rows == cards, idempotent apply';
2072
- });
2073
- }
2074
-
2075
- function m27ProcedureTolerance(runCase, assert, TMP) {
2076
- runCase('M27', 'procedures/ 空目录与坏 frontmatter:不抛错,坏卡仍计数并标注', () => {
2077
- const tool = procedureTool(assert);
2078
-
2079
- // 1) The directory does not exist at all.
2080
- const bare = fixture(TMP, 'm27-bare');
2081
- put(bare, 'topics/只有主题.md', topicCard('只有主题'));
2082
- const bareDry = memoryIndex({}, { memoryRoot: bare, now: FIXED_NOW });
2083
- assert(bareDry.ok === true && bareDry.cards === 1 && bareDry.procedures === 0, `no dir: ${bareDry.cards}/${bareDry.procedures}`);
2084
- assert(bareDry.generated.includes('### 程序性(procedures/)'), 'the procedures subsection must exist even with no directory');
2085
- assert(countGeneratedRows(bareDry.generated) === 1, `no dir rows: ${countGeneratedRows(bareDry.generated)}`);
2086
-
2087
- // 2) The directory exists and is empty.
2088
- const empty = fixture(TMP, 'm27-empty');
2089
- fs.mkdirSync(path.join(empty, 'procedures'), { recursive: true });
2090
- put(empty, 'topics/只有主题.md', topicCard('只有主题'));
2091
- const emptyDry = memoryIndex({}, { memoryRoot: empty, now: FIXED_NOW });
2092
- assert(emptyDry.ok === true && emptyDry.procedures === 0, `empty dir: ${emptyDry.procedures}`);
2093
- assert(countGeneratedRows(emptyDry.generated) === 1, `empty dir rows: ${countGeneratedRows(emptyDry.generated)}`);
2094
- const emptyList = invokeTool(tool, { action: 'list' }, procedureOptions(empty));
2095
- assert(emptyList.ok === true && emptyList.value.count === 0, `empty store list: ${emptyList.value?.count}`);
2096
-
2097
- // 3) An unterminated frontmatter block must not take the index down.
2098
- const broken = fixture(TMP, 'm27-broken');
2099
- put(broken, 'topics/好卡.md', topicCard('好卡'));
2100
- put(broken, 'procedures/坏卡.md', '---\nname: 坏卡\ntrigger: [x]\n');
2101
- const brokenDry = memoryIndex({}, { memoryRoot: broken, now: FIXED_NOW });
2102
- assert(brokenDry.ok === true, `index threw on a broken procedure card: ${brokenDry.error?.message}`);
2103
- assert(brokenDry.cards === 2, `broken card must still be counted, cards=${brokenDry.cards}`);
2104
- assert(brokenDry.invalid === 1, `invalid: ${brokenDry.invalid}`);
2105
- assert(brokenDry.generated.includes('procedures/坏卡.md'), 'the broken card has no row');
2106
- assert(countGeneratedRows(brokenDry.generated) === 2, `broken rows: ${countGeneratedRows(brokenDry.generated)}`);
2107
-
2108
- // 4) A parsed card that misses a required field, plus a bare legacy card.
2109
- put(broken, 'procedures/缺字段.md', '---\nname: 缺字段\nsalience: 3\n---\n\n## 何时用\n\n随便。\n');
2110
- put(broken, 'procedures/纯正文.md', '# 纯正文\n\n没有 frontmatter 的旧卡。\n');
2111
- const fourDry = memoryIndex({}, { memoryRoot: broken, now: FIXED_NOW });
2112
- assert(fourDry.ok === true && fourDry.cards === 4, `cards=${fourDry.cards}, expected 4`);
2113
- assert(fourDry.invalid === 2, `invalid: ${fourDry.invalid}`);
2114
- assert(fourDry.legacy === 1, `legacy: ${fourDry.legacy}`);
2115
- assert(countGeneratedRows(fourDry.generated) === 4, `rows: ${countGeneratedRows(fourDry.generated)}`);
2116
-
2117
- const listed = invokeTool(tool, { action: 'list' }, procedureOptions(broken));
2118
- assert(listed.ok === true, `list threw on broken cards: ${listed.error?.message}`);
2119
- assert(listed.value.count === 3, `list should show 3 procedure cards, got ${listed.value.count}`);
2120
- assert(
2121
- listed.value.invalid.length === 2
2122
- && listed.value.invalid.some((item) => item.rel === 'procedures/坏卡.md')
2123
- && listed.value.invalid.some((item) => item.rel === 'procedures/缺字段.md'),
2124
- `invalid list: ${JSON.stringify(listed.value.invalid)}`,
2125
- );
2126
-
2127
- const before = snapshot(broken);
2128
- assertCodedError(assert, invokeTool(tool, { action: 'get', slug: '坏卡' }, procedureOptions(broken)), 'E_PROCEDURE_INVALID', 'get a broken card');
2129
- assertCodedError(assert, invokeTool(tool, { action: 'get', slug: '缺字段' }, procedureOptions(broken)), 'E_PROCEDURE_INVALID', 'get an incomplete card');
2130
- assertCodedError(
2131
- assert,
2132
- invokeTool(tool, { action: 'save', name: '缺字段', when: 'x', steps: ['y'] }, procedureOptions(broken)),
2133
- 'E_PROCEDURE_INVALID',
2134
- 'save must not silently overwrite an incomplete card',
2135
- );
2136
- sameSnapshot(assert, before, snapshot(broken), 'tolerating broken cards must not write');
2137
- return 'absent/empty procedures/ indexed with zero rows; broken, incomplete and legacy cards counted and reported, never thrown';
2138
- });
2139
- }
2140
-
2141
- function m28Recall(runCase, assert, TMP) {
2142
- runCase('M28', 'memory_recall: 混合打分、分数排序、topK、room/status 过滤、locked 可命中、只读', () => {
2143
- const root = fixture(TMP, 'm28-recall');
2144
- put(root, 'topics/听觉.md', cardFile({
2145
- title: '听觉训练与离散进化',
2146
- tags: ['听觉', '训练'],
2147
- status: 'locked',
2148
- salience: 5,
2149
- body: '从听觉开始,靠离散训练长出人类那样的感觉。听觉是起点,训练是方法。',
2150
- }));
2151
- put(root, 'topics/编曲.md', cardFile({
2152
- title: '编曲与混音',
2153
- tags: ['arrangement'],
2154
- body: '鼓、贝斯、钢琴与混音;听觉只在验收时用一次。',
2155
- }));
2156
- put(root, 'journal/2026-11-02.md', cardFile({
2157
- title: '今天的日志',
2158
- date: FIXED_DAY,
2159
- body: '今天聊了听觉与训练,也聊了编曲。',
2160
- }));
2161
-
2162
- const tool = TOOLS.find((entry) => entry.name === 'memory_recall');
2163
- assert(tool, 'memory_recall is not registered');
2164
- const before = snapshot(root);
2165
-
2166
- const hit = invokeTool(tool, { query: '听觉 训练' }, { memoryRoot: root });
2167
- assert(hit.ok, `recall threw: ${hit.error?.message}`);
2168
- const value = hit.value;
2169
- assert(value.ok === true && Array.isArray(value.results), 'recall result shape is wrong');
2170
- assert(value.count === value.results.length && value.count >= 2, `count/results mismatch: ${value.count}/${value.results.length}`);
2171
- assert(value.results[0].path === 'topics/听觉.md', `top-1 is ${value.results[0].path}`);
2172
- assert(value.results[0].score > value.results[1].score, `scores are not descending: ${value.results.map((r) => r.score).join(',')}`);
2173
- for (const item of value.results) {
2174
- assert(Object.keys(item).sort().join(',') === 'excerpt,path,score,title', `result fields: ${Object.keys(item).join(',')}`);
2175
- assert(typeof item.title === 'string' && item.title.length > 0, 'title missing');
2176
- assert(typeof item.excerpt === 'string' && item.excerpt.length > 0, 'excerpt missing');
2177
- assert(typeof item.score === 'number' && Number.isFinite(item.score) && item.score > 0, `score invalid: ${item.score}`);
2178
- }
2179
- assert(value.results[0].excerpt.includes('听觉'), `excerpt does not contain the match: ${value.results[0].excerpt}`);
2180
-
2181
- // Reproducible: the same query on the same tree returns byte-identical hits.
2182
- const again = invokeTool(tool, { query: '听觉 训练' }, { memoryRoot: root });
2183
- assert(JSON.stringify(again.value.results) === JSON.stringify(value.results), 'two identical recalls returned different results');
2184
-
2185
- const one = invokeTool(tool, { query: '听觉', topK: 1 }, { memoryRoot: root });
2186
- assert(one.value.results.length === 1 && one.value.topK === 1, 'topK was not applied');
2187
-
2188
- const room = invokeTool(tool, { query: '听觉', room: 'topics' }, { memoryRoot: root });
2189
- assert(room.value.results.length > 0 && room.value.results.every((r) => r.path.startsWith('topics/')), 'room filter leaked journal cards');
2190
-
2191
- const locked = invokeTool(tool, { query: '听觉', status: 'locked' }, { memoryRoot: root });
2192
- assert(locked.value.results.length === 1 && locked.value.results[0].path === 'topics/听觉.md', `status=locked filter: ${JSON.stringify(locked.value.results)}`);
2193
-
2194
- sameSnapshot(assert, before, snapshot(root), 'recall must be read-only');
2195
- assert(!fs.existsSync(path.join(root, '.longlive')), 'recall created .longlive/');
2196
- return `${value.count} hits, top1=${value.results[0].score}, locked card recalled, tree unchanged`;
2197
- });
2198
- }
2199
-
2200
- function m29RecallErrors(runCase, assert, TMP) {
2201
- runCase('M29', 'memory_recall: 空 query 抛 E_RECALL_EMPTY_QUERY;无命中返回空列表 + E_RECALL_NO_MATCH 不抛', () => {
2202
- const root = fixture(TMP, 'm29-recall-errors');
2203
- put(root, 'topics/one.md', cardFile({ title: '唯一卡片', body: '这里只有一段普通正文。' }));
2204
- const tool = TOOLS.find((entry) => entry.name === 'memory_recall');
2205
- assert(tool, 'memory_recall is not registered');
2206
- for (const query of [undefined, null, '', ' ', '\t\n', 42]) {
2207
- assertCodedError(
2208
- assert,
2209
- invokeTool(tool, query === undefined ? {} : { query }, { memoryRoot: root }),
2210
- 'E_RECALL_EMPTY_QUERY',
2211
- `query=${JSON.stringify(query)}`,
2212
- );
2213
- }
2214
-
2215
- const miss = invokeTool(tool, { query: 'zzzqqqxxx' }, { memoryRoot: root });
2216
- assert(miss.ok, `no-match must not throw: ${miss.error?.message}`);
2217
- assert(miss.value.ok === true, 'no-match ok flag');
2218
- assert(miss.value.code === 'E_RECALL_NO_MATCH', `no-match code: ${miss.value.code}`);
2219
- assert(miss.value.count === 0 && Array.isArray(miss.value.results) && miss.value.results.length === 0, 'no-match results are not empty');
2220
- assert(typeof miss.value.message === 'string' && miss.value.message.length > 0, 'no-match message missing');
2221
- return 'empty queries coded; no-match is an empty list with a hint, not a throw';
2222
- });
2223
- }
2224
-
2225
- function m30LockedImmune(runCase, assert, TMP) {
2226
- runCase('M30', 'locked 卡免疫 forget:dry-run 不进提案,执行后仍在原位且字节未改', () => {
2227
- const root = fixture(TMP, 'm30-locked-forget');
2228
- put(root, 'topics/locked-core.md', cardFile({
2229
- title: '核心记忆第七条', status: 'locked', salience: 3, strength: 1, lastUsed: '2020-01-01',
2230
- body: '从听觉开始,靠离散训练长出人类那样的感觉,不是工具是伙伴。',
2231
- }));
2232
- put(root, 'topics/stale.md', cardFile({
2233
- title: '旧卡', salience: 3, strength: 1, lastUsed: '2020-01-01', body: '早该被忘掉的旧卡。',
2234
- }));
2235
- const lockedAbs = path.join(root, 'topics', 'locked-core.md');
2236
- const lockedBytes = fs.readFileSync(lockedAbs, 'utf8');
2237
- const before = snapshot(root);
2238
-
2239
- const dry = memoryForget({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2240
- assert(dry.dryRun === true, 'forget default is not dry-run');
2241
- assert(dry.proposals.every((p) => p.path !== 'topics/locked-core.md'), 'locked card leaked into forget proposals');
2242
- const immune = dry.immune.find((entry) => entry.path === 'topics/locked-core.md');
2243
- assert(immune, `locked card missing from immune[]: ${JSON.stringify(dry.immune)}`);
2244
- assert(/locked/i.test(String(immune.reason)), `immune reason does not name locked: ${immune.reason}`);
2245
- sameSnapshot(assert, before, snapshot(root), 'forget dry-run');
2246
-
2247
- const run = memoryForget({ confirm: 'FORGET' }, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2248
- assert(run.applied === true, 'confirmed forget did not run');
2249
- assert(fs.existsSync(lockedAbs), 'locked card was moved by forget');
2250
- assert(fs.readFileSync(lockedAbs, 'utf8') === lockedBytes, 'locked card bytes changed');
2251
- assert(!fs.existsSync(path.join(root, 'topics', 'stale.md')), 'the active stale card was not moved');
2252
- const tombstones = fs.readFileSync(path.join(root, 'tombstones.md'), 'utf8');
2253
- assert(tombstones.includes('topics/stale.md') && !tombstones.includes('topics/locked-core.md'), 'tombstone lines do not match the moved set');
2254
- return 'locked card immune (reason names locked); stale card moved; locked bytes identical';
2255
- });
2256
- }
2257
-
2258
- function m31ConsolidateLocked(runCase, assert, TMP) { runCase('M31', 'locked 卡免疫 distill/consolidate:不作合并来源,也不作合并目标', () => {
2259
- const root = fixture(TMP, 'm31-locked-consolidate');
2260
- put(root, 'topics/locked.md', cardFile({
2261
- title: '编曲核心(锁定)', tags: ['编曲', '混音'], status: 'locked', salience: 5,
2262
- body: '编曲:鼓、贝斯、钢琴与混音的分层,先定节奏再配和声,最后混音让位给人声。',
2263
- }));
2264
- put(root, 'topics/active.md', cardFile({
2265
- title: '编曲笔记', tags: ['编曲', '混音'],
2266
- body: '编曲:鼓、贝斯、钢琴与混音的分层,先定节奏再配和声,最后混音让位给人声。',
2267
- }));
2268
- put(root, 'journal/2026-11-02.md', [
2269
- '---',
2270
- 'title: 2026-11-02',
2271
- `date: ${FIXED_DAY}`,
2272
- 'salience: 3',
2273
- 'strength: 3',
2274
- 'revisions: 0',
2275
- '---',
2276
- '',
2277
- '编曲:鼓、贝斯、钢琴与混音的分层,先定节奏再配和声,最后混音让位给人声。',
2278
- '',
2279
- ].join('\n'));
2280
-
2281
- const before = snapshot(root, ['.longlive']);
2282
- const report = memoryConsolidate({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2283
- assert(report.ok === true, `consolidate failed: ${report.message}`);
2284
- assert(Array.isArray(report.proposals) && report.proposals.length > 0, 'fixture produced no proposal');
2285
- assert(report.proposals.every((p) => p.target !== 'topics/locked.md'), 'locked card was used as a merge target');
2286
- assert(report.proposals.every((p) => p.source !== 'topics/locked.md'), 'locked card was used as a merge source');
2287
- assert(report.proposals.some((p) => p.target === 'topics/active.md'), `active card was not chosen: ${JSON.stringify(report.proposals.map((p) => p.target))}`);
2288
- assert(report.lockedSkipped >= 1, `lockedSkipped not reported: ${report.lockedSkipped}`);
2289
- sameSnapshot(assert, before, snapshot(root, ['.longlive']), 'consolidate must not edit cards');
2290
- return `${report.proposals.length} proposal(s) target the active card; locked card skipped (${report.lockedSkipped})`;
2291
- });
2292
- }
2293
-
2294
- function m32StatusFields(runCase, assert, expectCode) {
2295
- runCase('M32', 'status/salience: 缺省 active、locked/archived 可读写、非法值报 E_CARD_*_INVALID', () => {
2296
- const locked = parseFrontmatter('---\ntitle: x\nstatus: locked\nsalience: 9\n---\nBODY');
2297
- assert(locked.fields.status === 'locked' && locked.fields.salience === 9, 'locked/9 did not parse');
2298
- const plain = parseFrontmatter('---\ntitle: x\n---\nBODY');
2299
- assert(plain.fields.status === 'active', `missing status defaulted to ${plain.fields.status}`);
2300
- assert(parseFrontmatter('---\ntitle: x\nstatus: archived\n---\nBODY').fields.status === 'archived', 'archived did not parse');
2301
-
2302
- expectCode(() => parseFrontmatter('---\nstatus: frozen\n---\nBODY'), 'E_CARD_STATUS_INVALID');
2303
- expectCode(() => parseFrontmatter('---\nstatus: LOCKED\n---\nBODY'), 'E_CARD_STATUS_INVALID');
2304
- expectCode(() => parseFrontmatter('---\nsalience: 10\n---\nBODY'), 'E_CARD_SALIENCE_INVALID');
2305
- expectCode(() => parseFrontmatter('---\nsalience: 3.5\n---\nBODY'), 'E_CARD_SALIENCE_INVALID');
2306
-
2307
- expectCode(() => updateFrontmatter('---\ntitle: x\n---\nBODY', { status: 'frozen' }), 'E_CARD_STATUS_INVALID');
2308
- expectCode(() => updateFrontmatter('---\ntitle: x\n---\nBODY', { salience: 10 }), 'E_CARD_SALIENCE_INVALID');
2309
- expectCode(() => serializeFrontmatter({ ...DEFAULT_FIELDS, status: 'frozen' }, {}), 'E_CARD_STATUS_INVALID');
2310
- expectCode(() => serializeFrontmatter({ ...DEFAULT_FIELDS, salience: -1 }, {}), 'E_CARD_SALIENCE_INVALID');
2311
-
2312
- const updated = updateFrontmatter('---\ntitle: x\nsalience: 3\n---\nBODY', { status: 'locked', salience: 5 });
2313
- const reload = parseFrontmatter(updated);
2314
- assert(reload.fields.status === 'locked' && reload.fields.salience === 5, `update did not stick: ${updated}`);
2315
- assert(reload.body === 'BODY', 'body changed during status update');
2316
- // active is the default and is not written back, so legacy cards stay minimal.
2317
- const active = updateFrontmatter('---\ntitle: x\nsalience: 3\n---\nBODY', { status: 'active' });
2318
- assert(!/status\s*:/.test(active), `default active was serialized: ${active}`);
2319
- return 'locked/archived parse; active is the default; invalid status/salience coded on read and write';
2320
- });
2321
- }
2322
-
2323
- function m33IndexStatusStats(runCase, assert, TMP) {
2324
- runCase('M33', 'memory_index: 统计分别列 active / locked / archived,INDEX 计数器同步', () => {
2325
- const root = fixture(TMP, 'm33-index-status');
2326
- for (const [rel, title, status] of [
2327
- ['topics/a.md', '主动一', 'active'],
2328
- ['topics/b.md', '主动二', 'active'],
2329
- ['topics/c.md', '锁定三', 'locked'],
2330
- ['topics/d.md', '归档四', 'archived'],
2331
- ]) {
2332
- put(root, rel, cardFile({ title, status, salience: status === 'locked' ? 5 : 3 }));
2333
- }
2334
- put(root, 'topics/legacy.md', '# 旧卡\n\n没有 frontmatter 的老卡,默认 active。\n');
2335
- put(root, 'INDEX.md', `# 索引\n\n${BEGIN_GENERATED}\n${END_GENERATED}\n`);
2336
-
2337
- const dry = memoryIndex({}, { memoryRoot: root, now: FIXED_NOW });
2338
- assert(dry.active === 3 && dry.locked === 1 && dry.archived === 1, `counts: active=${dry.active} locked=${dry.locked} archived=${dry.archived}`);
2339
- assert(dry.generated.includes('active: 3') && dry.generated.includes('locked: 1') && dry.generated.includes('archived: 1'), `counter line lacks status counts: ${dry.generated.split('\n')[2]}`);
2340
-
2341
- const applied = memoryIndex({ apply: true }, { memoryRoot: root, now: FIXED_NOW });
2342
- assert(applied.applied === true, 'apply failed');
2343
- const index = fs.readFileSync(path.join(root, 'INDEX.md'), 'utf8');
2344
- assert(index.includes('active: 3') && index.includes('locked: 1') && index.includes('archived: 1'), 'INDEX.md does not carry the status counts');
2345
- return 'active=3 (2 explicit + 1 legacy), locked=1, archived=1 in result and INDEX';
2346
- });
2347
- }
2348
-
2349
- function m34ScopeRouting(runCase, assert, TMP) {
2350
- runCase('M34', 'consolidate: scope 是 frontmatter 字段,并按 scope 覆盖率(×3)选卡', () => {
2351
- const parsed = parseFrontmatter('---\ntitle: t\nscope: 母带, LUFS,动态\n---\nBODY');
2352
- assert(parsed.fields.scope === '母带, LUFS,动态', `scope did not parse into fields: ${parsed.fields.scope}`);
2353
- assert(!('scope' in parsed.extras), 'scope is still treated as an unknown key');
2354
- const round = parseFrontmatter(serializeFrontmatter(parsed.fields, parsed.extras));
2355
- assert(round.fields.scope === '母带, LUFS,动态', 'scope did not survive serialization');
2356
- const updated = updateFrontmatter('---\ntitle: t\n---\nBODY', { scope: '母带 LUFS' });
2357
- assert(/scope:\s*母带 LUFS/.test(updated), `updateFrontmatter did not write scope: ${updated}`);
2358
-
2359
- const root = fixture(TMP, 'm34-scope-routing');
2360
- put(root, 'topics/arrangement.md', cardFile({ title: '编曲笔记', tags: ['编曲'], body: '鼓、贝斯、和声。' }));
2361
- put(root, 'topics/mastering.md', cardFile({ title: '音频笔记', scope: '母带 LUFS 动态 响度', body: '待补。' }));
2362
- put(root, 'journal/2026-11-02.md', journalFile(FIXED_DAY, [
2363
- '结论:母带响度按 LUFS 处理,动态范围不达标就重做。',
2364
- ]));
2365
- const result = memoryConsolidate({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2366
- assert(result.ok === true && result.proposals.length === 1, `expected one proposal: ${result.message}`);
2367
- const proposal = result.proposals[0];
2368
- assert(proposal.target === 'topics/mastering.md', `scope did not route the paragraph: ${proposal.target} / ${proposal.reason}`);
2369
- assert(proposal.reason.includes('原文引用') && proposal.reason.includes('话题命中'), `reason lost routing evidence: ${proposal.reason}`);
2370
- assert(/\d+%/.test(proposal.reason) === false, `a hit reason should not print a coverage percentage: ${proposal.reason}`);
2371
- return `scope round-trips and routes to ${proposal.target}`;
2372
- });
2373
- }
2374
-
2375
- function m35ContentTokens(runCase, assert, TMP) {
2376
- runCase('M35', 'contentTokens: 剥 URL/域名/纯数字/年份,CJK bigram 去停用词,不再靠日期选中卡', () => {
2377
- const tokens = contentTokens('参见 https://example.com/a/2026 与 docs.acestudio.ai,2026-10-27 复跑 surfaceOp 12 次。');
2378
- for (const noise of ['https', 'example', 'com', 'acestudio', 'ai', '2026', '12']) {
2379
- assert(!tokens.has(noise), `noise token survived: ${noise} in ${JSON.stringify([...tokens.keys()])}`);
2380
- }
2381
- assert(tokens.has('surface') || tokens.has('surfaceop'), `identifier token lost: ${JSON.stringify([...tokens.keys()])}`);
2382
- assert(tokens.size > 0 && [...tokens.values()].every((count) => count > 0), 'token counts must be positive');
2383
-
2384
- const root = fixture(TMP, 'm35-date-routing');
2385
- put(root, 'topics/dated.md', cardFile({ title: '2026-10-27 记录', body: '数字与日期。' }));
2386
- put(root, 'journal/2026-11-02.md', journalFile(FIXED_DAY, [
2387
- '2026-10-27 决定再跑一次。',
2388
- ]));
2389
- const result = memoryConsolidate({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2390
- assert(result.proposals.length === 1, `expected one proposal: ${result.proposals.length}`);
2391
- assert(result.proposals[0].target === null, `date-only overlap routed to ${result.proposals[0].target}`);
2392
- assert(result.proposals[0].reason.includes('建议新建卡'), `abstain reason missing: ${result.proposals[0].reason}`);
2393
- return `noise tokens dropped; date-only paragraph abstains`;
2394
- });
2395
- }
2396
-
2397
- function m36ClaimRouting(runCase, assert, TMP) {
2398
- runCase('M36', 'consolidate: 结论句路由;症状句再多也不改目标', () => {
2399
- const root = fixture(TMP, 'm36-claim-routing');
2400
- put(root, 'topics/mastering.md', cardFile({ title: '母带笔记', scope: '母带 LUFS 响度' }));
2401
- put(root, 'topics/arrangement.md', cardFile({ title: '编曲笔记', scope: '编曲 鼓 贝斯 和声' }));
2402
- put(root, 'journal/2026-11-02.md', journalFile(FIXED_DAY, [
2403
- '今天又把编曲过了一遍,鼓、贝斯与和声都逐个试过,鼓组和贝斯听起来都没问题,和声也没有打架。结论:母带 LUFS 响度超标,编曲不用改。',
2404
- ]));
2405
- const result = memoryConsolidate({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2406
- assert(result.proposals.length === 1, `expected one proposal: ${result.proposals.length}`);
2407
- const proposal = result.proposals[0];
2408
- assert(proposal.target === 'topics/mastering.md', `claim routing failed, target=${proposal.target}`);
2409
- assert(proposal.claim === '结论:母带 LUFS 响度超标,编曲不用改。', `claim sentence wrong: ${proposal.claim}`);
2410
- assert(proposal.reason.includes('原文引用『结论:母带 LUFS 响度超标,编曲不用改。』'), `reason should quote the claim: ${proposal.reason}`);
2411
- return `conclusion sentence routed to ${proposal.target}`;
2412
- });
2413
- }
2414
-
2415
- function m37QuoteRequired(runCase, assert, TMP) {
2416
- runCase('M37', 'consolidate: quote 必须是原文精确子串,理由与报告都带引用', () => {
2417
- const root = fixture(TMP, 'm37-quote');
2418
- put(root, 'topics/memory.md', cardFile({ title: '记忆日志', scope: '记忆 日志 备份' }));
2419
- const paragraph = '决定:记忆日志按天备份。备份文件放在 sessions 目录之外,每天一次。';
2420
- put(root, 'journal/2026-11-02.md', journalFile(FIXED_DAY, [paragraph]));
2421
- const result = memoryConsolidate({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2422
- assert(result.proposals.length === 1, `expected one proposal: ${result.proposals.length}`);
2423
- const proposal = result.proposals[0];
2424
- assert(typeof proposal.quote === 'string' && proposal.quote.length > 0, `proposal has no quote: ${proposal.quote}`);
2425
- assert(paragraph.includes(proposal.quote), `quote is not an exact substring: ${proposal.quote}`);
2426
- assert(proposal.reason.startsWith(`原文引用『${proposal.quote}』`), `reason does not lead with the quote: ${proposal.reason}`);
2427
- assert(proposal.quote.length <= 120, `quote too long: ${proposal.quote.length}`);
2428
- const report = fs.readFileSync(path.join(root, '.longlive', 'reports', 'consolidate-2026-11-02.md'), 'utf8');
2429
- assert(report.includes(`- 原文引用:${proposal.quote}`), 'report is missing the exact quote');
2430
- return `quote kept verbatim (${proposal.quote.length} chars) and echoed in the report`;
2431
- });
2432
- }
2433
-
2434
- function m38Abstain(runCase, assert, TMP) {
2435
- runCase('M38', 'consolidate: 排期指令与低覆盖都弃权,并建议新建卡', () => {
2436
- const root = fixture(TMP, 'm38-abstain');
2437
- put(root, 'topics/music.md', cardFile({ title: '音乐体系', scope: '音乐 体系 审计 章节' }));
2438
- put(root, 'topics/groceries.md', cardFile({ title: '采购', scope: '牛奶 面包' }));
2439
- put(root, 'journal/2026-11-02.md', journalFile(FIXED_DAY, [
2440
- '音乐体系审计先挂起,排在记忆模块之后,在他点名前不动音乐。',
2441
- '',
2442
- '今天顺手把桌面收拾了一下,别的什么都没动。',
2443
- ]));
2444
- const result = memoryConsolidate({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2445
- assert(result.proposals.length === 2, `expected two proposals: ${result.proposals.length}`);
2446
- const [plan, low] = result.proposals;
2447
- assert(plan.target === null, `plan paragraph was routed to ${plan.target}`);
2448
- assert(plan.reason.includes('建议新建卡'), `plan abstain reason missing: ${plan.reason}`);
2449
- assert(low.target === null, `low-coverage paragraph was routed to ${low.target}`);
2450
- assert(/最高覆盖\s*\d+%/.test(low.reason), `low-coverage reason missing the percentage: ${low.reason}`);
2451
- return `both paragraphs abstain: plan + ${low.reason.match(/最高覆盖\s*\d+%/)?.[0]}`;
2452
- });
2453
- }
2454
-
2455
- function m39EntryUnits(runCase, assert, TMP) {
2456
- runCase('M39', 'F6 paragraphUnits: unit 带 startLine/endLine,entrySplit 才在时间戳/结论行切', () => {
2457
- const body = [
2458
- '## 2026-11-02 日志',
2459
- '- 症状:日志坏了。',
2460
- '- 决定:先备份。',
2461
- '- **2026-10-30 12:38** 日志截断成功。',
2462
- '- 结论:备份放在 sessions 外。',
2463
- ].join('\n');
2464
- const flat = paragraphUnits(body);
2465
- assert(Array.isArray(flat) && flat.length === 1, `default units should stay whole: ${JSON.stringify(flat)}`);
2466
- assert(flat[0].startLine === 2 && flat[0].endLine === 5, `unit range wrong: ${flat[0].startLine}-${flat[0].endLine}`);
2467
- assert(flat[0].text.startsWith('- 症状:日志坏了。'), `heading leaked into unit: ${flat[0].text}`);
2468
-
2469
- const split = paragraphUnits(body, { splitEntries: true });
2470
- assert(split.length === 4, `entry split produced ${split.length} units`);
2471
- assert(split.map((unit) => unit.startLine).join(',') === '2,3,4,5', `startLine: ${split.map((unit) => unit.startLine).join(',')}`);
2472
- assert(split.every((unit) => unit.endLine >= unit.startLine), 'endLine must not precede startLine');
2473
-
2474
- const root = fixture(TMP, 'm39-entry-units');
2475
- put(root, 'topics/ops.md', cardFile({ title: '运维日志', scope: '日志 备份 截断' }));
2476
- put(root, 'journal/2026-11-02.md', journalFile(FIXED_DAY, body.split('\n')));
2477
- const flatRun = memoryConsolidate({}, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2478
- assert(flatRun.proposals.length === 1, `default mode should emit one proposal: ${flatRun.proposals.length}`);
2479
- const splitRun = memoryConsolidate({ entrySplit: true }, { memoryRoot: root, today: FIXED_DAY, now: FIXED_NOW });
2480
- assert(splitRun.proposals.length === 4, `entrySplit should emit four proposals: ${splitRun.proposals.length}`);
2481
- assert(new Set(splitRun.proposals.map((p) => p.lineStart)).size === 4, 'entry proposals reuse a line number');
2482
- return `1 unit by default, 4 with entrySplit (lines 2-5)`;
2483
- });
2484
- }
2485
-
2486
- function m40Provenance(runCase, assert, TMP) {
2487
- runCase('M40', 'B3 provenance: 新卡写 source_session/source_turn/written_at,合并时保留旧值', () => {
2488
- const root = fixture(TMP, 'm40-provenance');
2489
- const tool = procedureTool(assert);
2490
- const options = { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY };
2491
- const exec = { agent: { id: 'session-b3' } };
2492
-
2493
- const created = invokeTool(tool, {
2494
- action: 'save',
2495
- name: '来源戳',
2496
- trigger: ['provenance'],
2497
- when: '写一张卡并记录它从哪来。',
2498
- steps: ['记录 session 与 turn'],
2499
- }, { ...options, exec, turn: 7 });
2500
- assert(created.ok === true, `save failed: ${created.error?.message}`);
2501
- const abs = path.join(root, created.value.rel);
2502
- const firstText = fs.readFileSync(abs, 'utf8');
2503
- const first = parseCardForAssertions(firstText);
2504
- assert(first.fields.source_session === 'session-b3', `source_session: ${first.fields.source_session}`);
2505
- assert(first.fields.source_turn === '7', `source_turn: ${first.fields.source_turn}`);
2506
- assert(first.fields.written_at === FIXED_NOW.toISOString(), `written_at: ${first.fields.written_at}`);
2507
- const keys = firstText.split('\n').slice(1, 7).map((line) => line.slice(0, line.indexOf(':')));
2508
- assert(
2509
- keys.join(',') === 'name,trigger,salience,strength,last_used,revisions',
2510
- `source extras must not disturb the pinned six keys — got ${keys.join(',')}`,
2511
- );
2512
-
2513
- const manifest = readManifest(root);
2514
- assert(manifest !== null && manifest.cards.length === 1, `manifest cards: ${manifest?.cards.length}`);
2515
- const row = manifest.cards[0];
2516
- assert(row.path === created.value.rel, `manifest path: ${row.path}`);
2517
- assert(row.source?.session === 'session-b3' && row.source?.turn === '7', `manifest source: ${JSON.stringify(row.source)}`);
2518
- assert(row.written_at === FIXED_NOW.toISOString(), `manifest written_at: ${row.written_at}`);
2519
- assert(verifyManifest(root).ok === true, 'a freshly saved card must verify clean');
2520
-
2521
- // A merge writes a new version but must not rewrite the original stamps.
2522
- const merged = invokeTool(tool, {
2523
- action: 'save',
2524
- name: '来源戳',
2525
- steps: ['记录 session 与 turn', '合并时保留来源'],
2526
- }, { ...options, exec: { agent: { id: 'session-other' } }, turn: 9 });
2527
- assert(merged.ok === true, `merge failed: ${merged.error?.message}`);
2528
- const second = parseCardForAssertions(fs.readFileSync(abs, 'utf8'));
2529
- assert(second.fields.source_session === 'session-b3', `merge rewrote source_session: ${second.fields.source_session}`);
2530
- assert(second.fields.source_turn === '7', `merge rewrote source_turn: ${second.fields.source_turn}`);
2531
- assert(second.fields.written_at === FIXED_NOW.toISOString(), `merge rewrote written_at: ${second.fields.written_at}`);
2532
-
2533
- // No host session at all: the honest label is manual/unknown, never a guess.
2534
- const manual = invokeTool(tool, {
2535
- action: 'save',
2536
- name: '手工卡',
2537
- trigger: ['手工'],
2538
- when: '没有会话上下文时手工写卡。',
2539
- steps: ['写 manual'],
2540
- }, options);
2541
- assert(manual.ok === true, `manual save failed: ${manual.error?.message}`);
2542
- const manualFields = parseCardForAssertions(fs.readFileSync(path.join(root, manual.value.rel), 'utf8')).fields;
2543
- assert(manualFields.source_session === 'manual', `manual session: ${manualFields.source_session}`);
2544
- assert(manualFields.source_turn === 'unknown', `manual turn: ${manualFields.source_turn}`);
2545
-
2546
- return `source stamped (${manualFields.source_session}/${manualFields.source_turn}), merge kept the first stamp`;
2547
- });
2548
- }
2549
-
2550
- function m41Conflict(runCase, assert, TMP) {
2551
- runCase('M41', 'B3 conflict: 同 slug/同 title/同 key 反结论才标记,旧卡正文不被覆盖', () => {
2552
- const root = fixture(TMP, 'm41-conflict');
2553
- const oldRel = 'topics/记忆后端.md';
2554
- const oldText = [
2555
- '---',
2556
- 'title: 记忆后端选型',
2557
- 'date: 2026-11-01',
2558
- 'salience: 4',
2559
- 'strength: 3',
2560
- 'revisions: 1',
2561
- 'key: 记忆后端',
2562
- '---',
2563
- '# 记忆后端选型',
2564
- '',
2565
- '结论:使用 SQLite 作为记忆后端。',
2566
- '',
2567
- ].join('\n');
2568
- put(root, oldRel, oldText);
2569
- const oldBody = parseCardForAssertions(oldText).body;
2570
-
2571
- const opposite = detectConflicts(root, {
2572
- rel: 'topics/反方.md',
2573
- title: '记忆后端反方',
2574
- body: '# 记忆后端反方\n\n结论:不使用 SQLite 作为记忆后端。\n',
2575
- key: '记忆后端',
2576
- });
2577
- assert(opposite.length === 1 && opposite[0].rel === oldRel, `opposite conflicts: ${JSON.stringify(opposite)}`);
2578
- assert(opposite[0].reasons.includes('same-key-opposite-conclusion'), `opposite reasons: ${opposite[0].reasons}`);
2579
-
2580
- const sameTitle = detectConflicts(root, {
2581
- rel: 'topics/同名.md',
2582
- title: '记忆后端选型',
2583
- body: '# 记忆后端选型\n\n结论:使用 Postgres 作为记忆后端。\n',
2584
- key: '数据库',
2585
- });
2586
- assert(sameTitle.some((row) => row.reasons.includes('same-title')), `same-title conflicts: ${JSON.stringify(sameTitle)}`);
2587
-
2588
- const sameSlug = detectConflicts(root, {
2589
- rel: oldRel,
2590
- title: '记忆后端选型',
2591
- body: '# 记忆后端选型\n\n结论:使用 Postgres 作为记忆后端。\n',
2592
- key: '记忆后端',
2593
- });
2594
- assert(sameSlug.some((row) => row.reasons.includes('same-slug')), `same-slug conflicts: ${JSON.stringify(sameSlug)}`);
2595
-
2596
- // Same key, same polarity: an alternative, not a contradiction.
2597
- const alternative = detectConflicts(root, {
2598
- rel: 'topics/备选.md',
2599
- title: '另一个后端',
2600
- body: '# 另一个后端\n\n结论:使用 Postgres 作为记忆后端。\n',
2601
- key: '记忆后端',
2602
- });
2603
- assert(alternative.length === 0, `same-polarity alternative was reported as conflict: ${JSON.stringify(alternative)}`);
2604
-
2605
- // Unrelated conclusion and key: must stay quiet.
2606
- const unrelated = detectConflicts(root, {
2607
- rel: 'topics/灰度.md',
2608
- title: '灰度方案',
2609
- body: '# 灰度方案\n\n结论:先灰度 10%。\n',
2610
- key: '发布',
2611
- });
2612
- assert(unrelated.length === 0, `unrelated card was reported as conflict: ${JSON.stringify(unrelated)}`);
2613
-
2614
- const newRel = 'topics/记忆后端-反方.md';
2615
- const result = writeCard(root, {
2616
- rel: newRel,
2617
- title: '记忆后端反方',
2618
- body: '# 记忆后端反方\n\n结论:不使用 SQLite 作为记忆后端。\n',
2619
- key: '记忆后端',
2620
- sessionId: 'session-b3',
2621
- turn: 12,
2622
- }, { now: FIXED_NOW });
2623
- assert(result.status === 'created' && result.conflicts.length === 1, `writeCard conflicts: ${JSON.stringify(result.conflicts)}`);
2624
- assert(result.conflicts[0].rel === oldRel, `conflict target: ${result.conflicts[0].rel}`);
2625
- assert(result.conflictWith.includes(oldRel), `conflictWith: ${JSON.stringify(result.conflictWith)}`);
2626
-
2627
- const newText = fs.readFileSync(path.join(root, newRel), 'utf8');
2628
- const newFields = parseCardForAssertions(newText).fields;
2629
- assert(newFields.conflict === `[${oldRel}]`, `new conflict marker: ${newFields.conflict}`);
2630
- assert(newFields.source_session === 'session-b3' && newFields.source_turn === '12', 'new card lost provenance');
2631
- assert(newFields.written_at === FIXED_NOW.toISOString(), `new written_at: ${newFields.written_at}`);
2632
-
2633
- const oldAfterText = fs.readFileSync(path.join(root, oldRel), 'utf8');
2634
- const oldAfter = parseCardForAssertions(oldAfterText);
2635
- assert(oldAfter.fields.conflict_with === `[${newRel}]`, `old conflict_with: ${oldAfter.fields.conflict_with}`);
2636
- assert(oldAfter.fields.title === '记忆后端选型' && oldAfter.fields.revisions === '1', 'old card frontmatter was rewritten');
2637
- assert(normalizedBody(oldAfter.body) === normalizedBody(oldBody), 'old card body changed');
2638
- assert(oldAfterText.includes('结论:使用 SQLite 作为记忆后端。'), 'old conclusion was replaced');
2639
- assert(!oldAfterText.includes('不使用 SQLite 作为记忆后端。'), 'new conclusion leaked into the old body');
2640
-
2641
- const manifest = readManifest(root);
2642
- assert(manifest.cards.some((row) => row.path === newRel), 'new card missing from manifest');
2643
- assert(verifyManifest(root).ok === true, 'manifest must match both cards after a conflict write');
2644
-
2645
- // Same rel again: refused, and nothing on disk moves (not even a backup).
2646
- const before = snapshot(root);
2647
- let duplicate = null;
2648
- try {
2649
- writeCard(root, {
2650
- rel: newRel,
2651
- title: '记忆后端反方',
2652
- body: '# 记忆后端反方\n\n结论:使用 SQLite 作为记忆后端。\n',
2653
- key: '记忆后端',
2654
- }, { now: FIXED_NOW });
2655
- } catch (error) {
2656
- duplicate = error;
2657
- }
2658
- assert(duplicate !== null && duplicate.code === 'E_CARD_EXISTS', `duplicate write: ${duplicate?.code ?? duplicate}`);
2659
- sameSnapshot(assert, before, snapshot(root), 'E_CARD_EXISTS must be a no-op');
2660
-
2661
- // The existing procedure writer is the other real write path: a new
2662
- // procedure must get the same marker when an older card shares its title.
2663
- put(root, 'topics/部署流程.md', cardFile({ title: '部署流程', body: '旧结论:手工部署。' }));
2664
- const procedure = invokeTool(procedureTool(assert), {
2665
- action: 'save',
2666
- name: '部署流程',
2667
- trigger: ['部署'],
2668
- when: '要部署时。',
2669
- steps: ['跑测试'],
2670
- }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
2671
- assert(procedure.ok === true, `procedure save failed: ${procedure.error?.message}`);
2672
- assert(procedure.value.conflicts?.some((row) => row.rel === 'topics/部署流程.md'), `procedure conflicts: ${JSON.stringify(procedure.value.conflicts)}`);
2673
- const procedureFields = parseCardForAssertions(fs.readFileSync(path.join(root, procedure.value.rel), 'utf8')).fields;
2674
- assert(procedureFields.conflict === '[topics/部署流程.md]', `procedure conflict marker: ${procedureFields.conflict}`);
2675
- const topicAfter = parseCardForAssertions(fs.readFileSync(path.join(root, 'topics/部署流程.md'), 'utf8'));
2676
- assert(topicAfter.fields.conflict_with === `[${procedure.value.rel}]`, `topic conflict_with: ${topicAfter.fields.conflict_with}`);
2677
- assert(verifyManifest(root).ok === true, 'manifest must include the procedure conflict write');
2678
-
2679
- return 'opposite key flagged, alternative/unrelated quiet, old body byte-identical, duplicate refused, procedure path marked too';
2680
- });
2681
- }
2682
-
2683
- function m42Manifest(runCase, assert, TMP) {
2684
- runCase('M42', 'B3 manifest: sha256 基线可校验,能发现被改/缺失/未登记/损坏', () => {
2685
- const root = fixture(TMP, 'm42-manifest');
2686
- put(root, 'topics/a.md', cardFile({ title: '甲', body: '甲的内容。' }));
2687
- put(root, 'procedures/b.md', [
2688
- '---',
2689
- 'name: 乙',
2690
- 'trigger: [乙]',
2691
- 'salience: 3',
2692
- 'strength: 3',
2693
- 'last_used: 2026-11-01',
2694
- 'revisions: 0',
2695
- '---',
2696
- '',
2697
- '## 何时用',
2698
- '',
2699
- '乙。',
2700
- '',
2701
- '## 怎么做',
2702
- '',
2703
- '1. 做乙。',
2704
- '',
2705
- ].join('\n'));
2706
- // A broken card must still be tracked by its raw bytes, not hidden.
2707
- put(root, 'topics/broken.md', '---\ntitle: 坏卡\n');
2708
-
2709
- const refreshed = refreshManifest(root, { now: FIXED_NOW });
2710
- assert(refreshed.manifest.cards.length === 3, `manifest cards: ${refreshed.manifest.cards.length}`);
2711
- const manifest = readManifest(root);
2712
- assert(manifest.cards.map((row) => row.path).join(',') === 'procedures/b.md,topics/a.md,topics/broken.md', `manifest order: ${manifest.cards.map((row) => row.path)}`);
2713
- for (const row of manifest.cards) {
2714
- assert(/^[a-f0-9]{64}$/.test(row.sha256), `bad sha for ${row.path}: ${row.sha256}`);
2715
- assert(Number.isFinite(Date.parse(row.written_at)), `bad written_at for ${row.path}: ${row.written_at}`);
2716
- assert(row.source === null, `legacy card should have no source: ${JSON.stringify(row.source)}`);
2717
- }
2718
-
2719
- const manifestBytes = fs.readFileSync(manifestPath(root));
2720
- const clean = verifyManifest(root);
2721
- assert(clean.ok === true && clean.checked === 3, `clean verify: ok=${clean.ok} checked=${clean.checked}`);
2722
- assert(clean.changed.length === 0 && clean.missing.length === 0 && clean.untracked.length === 0, 'clean verify reported problems');
2723
- assert(fs.readFileSync(manifestPath(root)).equals(manifestBytes), 'verifyManifest must be read-only');
2724
-
2725
- fs.appendFileSync(path.join(root, 'topics/a.md'), '\n补充。\n', 'utf8');
2726
- const changed = verifyManifest(root);
2727
- assert(changed.ok === false && changed.changed.length === 1, `changed verify: ${JSON.stringify(changed.changed)}`);
2728
- assert(changed.changed[0].path === 'topics/a.md', `changed path: ${changed.changed[0].path}`);
2729
- assert(changed.changed[0].expected_sha256 !== changed.changed[0].actual_sha256, 'changed hashes are equal');
2730
- assert(changed.changed[0].actual_sha256 === sha(fs.readFileSync(path.join(root, 'topics/a.md'), 'utf8')), 'actual hash is not the disk hash');
2731
- assert(fs.readFileSync(manifestPath(root)).equals(manifestBytes), 'a failed verify wrote the manifest');
2732
-
2733
- fs.unlinkSync(path.join(root, 'procedures/b.md'));
2734
- put(root, 'journal/c.md', journalFile(FIXED_DAY, ['新卡。']));
2735
- const gone = verifyManifest(root);
2736
- assert(gone.ok === false, 'missing/untracked must fail verification');
2737
- assert(gone.missing.some((row) => row.path === 'procedures/b.md'), `missing: ${JSON.stringify(gone.missing)}`);
2738
- assert(gone.untracked.includes('journal/c.md'), `untracked: ${JSON.stringify(gone.untracked)}`);
2739
-
2740
- let parsedError = null;
2741
- fs.writeFileSync(manifestPath(root), '{ this is not json', 'utf8');
2742
- try {
2743
- readManifest(root);
2744
- } catch (error) {
2745
- parsedError = error;
2746
- }
2747
- assert(parsedError !== null && parsedError.code === 'E_MANIFEST_CORRUPT', `readManifest corrupt: ${parsedError?.code ?? parsedError}`);
2748
- const corrupt = verifyManifest(root);
2749
- assert(corrupt.ok === false && corrupt.code === 'E_MANIFEST_CORRUPT', `corrupt verify: ${corrupt.code}`);
2750
-
2751
- fs.unlinkSync(manifestPath(root));
2752
- const absent = verifyManifest(root);
2753
- assert(absent.ok === false && absent.code === 'E_MANIFEST_MISSING', `absent verify: ${absent.code}`);
2754
- assert(absent.untracked.includes('topics/a.md') && absent.untracked.includes('journal/c.md'), `absent untracked: ${JSON.stringify(absent.untracked)}`);
2755
-
2756
- // Rebuild repairs the store and the next check is clean again.
2757
- refreshManifest(root, { now: FIXED_NOW });
2758
- const repaired = verifyManifest(root);
2759
- assert(repaired.ok === true && repaired.checked === 3, `repaired verify: ok=${repaired.ok} checked=${repaired.checked}`);
2760
-
2761
- // A legitimate card edit must refresh an opted-in manifest, not look like
2762
- // tampering on the next startup check.
2763
- const reinforced = memoryReinforce({ path: 'topics/a.md', reason: 'B3' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
2764
- assert(reinforced.manifest && reinforced.manifest.manifest.cards.length === 3, 'reinforce did not refresh the manifest');
2765
- assert(verifyManifest(root).ok === true, 'manifest went stale after a legitimate reinforce');
2766
-
2767
- // Forgetting moves a card out of the indexed directories; the manifest must
2768
- // drop it in the same run instead of reporting it as missing on startup.
2769
- // Drop the intentionally broken card first: forget scans every card and is
2770
- // allowed to refuse a store it cannot read (that behavior is covered by M27).
2771
- fs.unlinkSync(path.join(root, 'topics/broken.md'));
2772
- put(root, 'topics/old.md', cardFile({ title: '旧卡', body: '很久没用。', lastUsed: '2024-01-01' }));
2773
- refreshManifest(root, { now: FIXED_NOW });
2774
- const forgot = memoryForget({ confirm: 'FORGET' }, { memoryRoot: root, now: FIXED_NOW, today: FIXED_DAY });
2775
- assert(forgot.moved === 1 && forgot.manifest, `forget did not refresh the manifest: moved=${forgot.moved}`);
2776
- assert(verifyManifest(root).ok === true, 'manifest went stale after a legitimate forget');
2777
- assert(readManifest(root).cards.every((row) => row.path !== 'topics/old.md'), 'forgotten card still in manifest');
2778
-
2779
- return 'changed/missing/untracked/corrupt/missing-manifest all reported; verify never wrote; reinforce/forget refresh';
2780
- });
2781
- }
2782
-
2783
- /** Entry point used by test/run.mjs. */
2784
- export function runMemorySuite({ runCase, runSymlinkCase, assert, expectCode, TMP }) {
2785
- m1Index(runCase, assert, expectCode, TMP);
2786
- m2Consolidate(runCase, assert, expectCode, TMP);
2787
- m3Reinforce(runCase, assert, TMP);
2788
- m4Forget(runCase, assert, expectCode, TMP);
2789
- m5PathGuard(runCase, assert, expectCode, TMP);
2790
- m6NoRoot(runCase, assert, expectCode);
2791
- m7Frontmatter(runCase, assert, expectCode);
2792
- m8State(runCase, assert, expectCode, TMP);
2793
- m9ConsolidateRules(runCase, assert, expectCode, TMP);
2794
- m10ReinforceDecay(runCase, assert, TMP);
2795
- m11ForgetThresholds(runCase, assert, expectCode, TMP);
2796
- m12ToolErrors(runCase, assert, expectCode, TMP);
2797
- m13BomRoundTrip(runCase, assert, expectCode, TMP);
2798
- m14MissingFieldEol(runCase, assert, TMP);
2799
- m15EmptyNumericDefaults(runCase, assert, expectCode);
2800
- m16ToolArgGuards(runCase, assert, TMP);
2801
- m17MultilineValues(runCase, assert, expectCode);
2802
- m18RealpathGuard(runCase, assert, expectCode, TMP);
2803
- m18SymlinkGuard(runSymlinkCase, assert, expectCode, TMP);
2804
- m19ReportBackupSameMs(runCase, assert, TMP);
2805
- m20AffectMarkers(runCase, assert, TMP);
2806
- m21DedupSince(runCase, assert, TMP);
2807
- m22ForgetRules(runCase, assert, TMP);
2808
- m23ForgetExecute(runCase, assert, TMP);
2809
- m24ProcedureSaveGetList(runCase, assert, TMP);
2810
- m25ProcedureTriggerReport(runCase, assert, TMP);
2811
- m26IndexProcedures(runCase, assert, TMP);
2812
- m27ProcedureTolerance(runCase, assert, TMP);
2813
- m28Recall(runCase, assert, TMP);
2814
- m29RecallErrors(runCase, assert, TMP);
2815
- m30LockedImmune(runCase, assert, TMP);
2816
- m31ConsolidateLocked(runCase, assert, TMP);
2817
- m32StatusFields(runCase, assert, expectCode);
2818
- m33IndexStatusStats(runCase, assert, TMP);
2819
- m34ScopeRouting(runCase, assert, TMP);
2820
- m35ContentTokens(runCase, assert, TMP);
2821
- m36ClaimRouting(runCase, assert, TMP);
2822
- m37QuoteRequired(runCase, assert, TMP);
2823
- m38Abstain(runCase, assert, TMP);
2824
- m39EntryUnits(runCase, assert, TMP);
2825
- m40Provenance(runCase, assert, TMP);
2826
- m41Conflict(runCase, assert, TMP);
2827
- m42Manifest(runCase, assert, TMP);
2828
- }