@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
@@ -0,0 +1,644 @@
1
+ // activity/store.js — the activity board's file layer (1.6.1).
2
+ //
3
+ // One append-only JSONL board for every session:
4
+ // <memoryRoot>/.longlive/board/activity.jsonl
5
+ // <memoryRoot>/.longlive/board/activity-YYYYMM.jsonl (overflow archive)
6
+ //
7
+ // Every append is a read-modify-write under a cross-process lock, then an
8
+ // atomic tmp -> fsync -> read-back -> rename. That combination is what makes
9
+ // two sessions writing at the same time produce unique, contiguous seq values
10
+ // instead of a lost update. The recent window is bounded (500 rows by default);
11
+ // old rows move to the monthly archive in the same transition so the board
12
+ // never grows without limit.
13
+ //
14
+ // The store never throws on an IO failure: it returns {ok:false, code}. The
15
+ // caller (index.js) is required to swallow that and let the turn continue.
16
+
17
+ import crypto from 'node:crypto';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+
21
+ import { privacyScan as defaultPrivacyScan } from '../recall/privacy.mjs';
22
+ import {
23
+ ACTIVITY_KINDS,
24
+ ACTIVITY_TEXT_MAX,
25
+ ACTIVITY_THINKING_TEXT_MAX,
26
+ DEDUPE_WINDOW,
27
+ DISTILL_MIN_COUNT,
28
+ DISTILL_TOTAL_COUNT,
29
+ SIMILARITY_THRESHOLD,
30
+ charLength,
31
+ clip,
32
+ distillActivityText,
33
+ normalizeKey,
34
+ normalizeText,
35
+ oneLine,
36
+ similarity,
37
+ } from './model.js';
38
+
39
+ export const ACTIVITY_SCHEMA_VERSION = 1;
40
+ export const ACTIVITY_BOARD_DIR = 'board';
41
+ export const ACTIVITY_FILE_NAME = 'activity.jsonl';
42
+ export const ACTIVITY_RECENT_MAX = 500;
43
+ export const ACTIVITY_LOCK_STALE_MS = 10000;
44
+ export const ACTIVITY_LOCK_TIMEOUT_MS = 2000;
45
+ export const ACTIVITY_SESSION_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
46
+ /** Monotonic seq high-water mark: survives board deletion/compaction. */
47
+ export const ACTIVITY_SEQ_HIGH_WATER_FILE = '.activity.seq';
48
+ /** Written when the store detects the board was reset (deleted/emptied). */
49
+ export const ACTIVITY_RESET_MARKER_FILE = 'stream.reset.json';
50
+
51
+ const ARCHIVE_RE = /^activity-(\d{6})\.jsonl$/;
52
+
53
+ export function activityBoardDir(memoryRoot) {
54
+ return path.join(String(memoryRoot ?? ''), '.longlive', ACTIVITY_BOARD_DIR);
55
+ }
56
+
57
+ export function activityBoardFile(memoryRoot) {
58
+ return path.join(activityBoardDir(memoryRoot), ACTIVITY_FILE_NAME);
59
+ }
60
+
61
+ /** `<board>/.activity.seq` — the seq high-water sidecar (RE-R15.5). */
62
+ export function activitySeqHighWaterFile(memoryRoot) {
63
+ return path.join(activityBoardDir(memoryRoot), ACTIVITY_SEQ_HIGH_WATER_FILE);
64
+ }
65
+
66
+ /** `<board>/stream.reset.json` — last detected board-reset evidence (RE-R15.5). */
67
+ export function activityResetMarkerFile(memoryRoot) {
68
+ return path.join(activityBoardDir(memoryRoot), ACTIVITY_RESET_MARKER_FILE);
69
+ }
70
+
71
+ export function activityArchiveFile(memoryRoot, monthKey) {
72
+ return path.join(activityBoardDir(memoryRoot), `activity-${monthKey}.jsonl`);
73
+ }
74
+
75
+ export function monthKeyFor(value) {
76
+ const date = value instanceof Date ? value : new Date(value ?? Date.now());
77
+ if (!Number.isFinite(date.getTime())) return '000000';
78
+ const year = date.getFullYear();
79
+ const month = String(date.getMonth() + 1).padStart(2, '0');
80
+ return `${year}${month}`;
81
+ }
82
+
83
+ export function sha256Hex(value) {
84
+ return crypto.createHash('sha256').update(String(value ?? ''), 'utf8').digest('hex');
85
+ }
86
+
87
+ function countOf(entry) {
88
+ const count = Number(entry?.count);
89
+ return Number.isInteger(count) && count > 0 ? count : 1;
90
+ }
91
+
92
+ function isObject(value) {
93
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
94
+ }
95
+
96
+ /** Parse JSONL defensively; bad lines are counted, never thrown. */
97
+ export function parseActivityJsonl(text) {
98
+ const entries = [];
99
+ let bad = 0;
100
+ for (const line of String(text ?? '').replace(/^\uFEFF/, '').split(/\r?\n/)) {
101
+ if (line.trim() === '') continue;
102
+ let row = null;
103
+ try {
104
+ row = JSON.parse(line);
105
+ } catch {
106
+ bad += 1;
107
+ continue;
108
+ }
109
+ if (!isObject(row) || !Number.isInteger(row.seq) || typeof row.text !== 'string' || typeof row.sessionId !== 'string') {
110
+ bad += 1;
111
+ continue;
112
+ }
113
+ entries.push(row);
114
+ }
115
+ entries.sort((left, right) => left.seq - right.seq);
116
+ return { entries, bad };
117
+ }
118
+
119
+ export function serializeActivityEntries(entries) {
120
+ return entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length > 0 ? '\n' : '');
121
+ }
122
+
123
+ function sleepSync(ms) {
124
+ try {
125
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
126
+ } catch {
127
+ /* fall through: a busy loop is still bounded by the deadline */
128
+ }
129
+ }
130
+
131
+ function writeTextDurable(file, text) {
132
+ const dir = path.dirname(file);
133
+ fs.mkdirSync(dir, { recursive: true });
134
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
135
+ const fd = fs.openSync(tmp, 'w');
136
+ try {
137
+ fs.writeFileSync(fd, text, 'utf8');
138
+ try {
139
+ fs.fsyncSync(fd);
140
+ } catch {
141
+ /* fsync is best effort on some filesystems */
142
+ }
143
+ } finally {
144
+ try {
145
+ fs.closeSync(fd);
146
+ } catch {
147
+ /* ignore */
148
+ }
149
+ }
150
+ let lastError = null;
151
+ for (let attempt = 0; attempt < 5; attempt += 1) {
152
+ try {
153
+ const readBack = fs.readFileSync(tmp, 'utf8');
154
+ if (readBack !== text) throw new Error('activity write read-back mismatch');
155
+ fs.renameSync(tmp, file);
156
+ return { ok: true };
157
+ } catch (error) {
158
+ lastError = error;
159
+ if (attempt < 4) sleepSync(10 * (attempt + 1));
160
+ }
161
+ }
162
+ try {
163
+ fs.rmSync(tmp, { force: true });
164
+ } catch {
165
+ /* ignore */
166
+ }
167
+ return { ok: false, error: lastError?.code ?? lastError?.message ?? 'E_WRITE' };
168
+ }
169
+
170
+ /** Atomic JSON write used by the per-session lastSeenSeq state. */
171
+ export function writeJsonAtomic(file, value) {
172
+ const text = `${JSON.stringify(value, null, 2)}\n`;
173
+ try {
174
+ const written = writeTextDurable(file, text);
175
+ return written.ok ? { ok: true, file, bytes: Buffer.byteLength(text, 'utf8') } : written;
176
+ } catch (error) {
177
+ return { ok: false, file, error: error?.code ?? error?.message ?? 'E_WRITE' };
178
+ }
179
+ }
180
+
181
+ export function readJsonFile(file) {
182
+ try {
183
+ return { ok: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) };
184
+ } catch (error) {
185
+ return { ok: false, error: error?.code ?? 'E_READ' };
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Write/read the whole recent board under `<board>/.activity.lock`. Returns
191
+ * `{ok:false,code:'E_LOCK_TIMEOUT'}` instead of blocking a turn forever.
192
+ */
193
+ function withLock(memoryRoot, fn) {
194
+ const dir = activityBoardDir(memoryRoot);
195
+ try {
196
+ fs.mkdirSync(dir, { recursive: true });
197
+ } catch (error) {
198
+ return { ok: false, code: 'E_MKDIR', message: error?.message ?? String(error) };
199
+ }
200
+ const lockFile = path.join(dir, '.activity.lock');
201
+ const started = Date.now();
202
+ let fd = null;
203
+ while (fd === null) {
204
+ try {
205
+ fd = fs.openSync(lockFile, 'wx');
206
+ } catch (error) {
207
+ if (error?.code !== 'EEXIST') {
208
+ return { ok: false, code: 'E_LOCK', message: error?.code ?? String(error) };
209
+ }
210
+ try {
211
+ const stat = fs.statSync(lockFile);
212
+ if (Date.now() - stat.mtimeMs > ACTIVITY_LOCK_STALE_MS) {
213
+ fs.rmSync(lockFile, { force: true });
214
+ continue;
215
+ }
216
+ } catch {
217
+ /* the other writer may have released it between open and stat */
218
+ }
219
+ if (Date.now() - started > ACTIVITY_LOCK_TIMEOUT_MS) {
220
+ return { ok: false, code: 'E_LOCK_TIMEOUT' };
221
+ }
222
+ sleepSync(10);
223
+ }
224
+ }
225
+ try {
226
+ try {
227
+ fs.writeSync(fd, `${process.pid} ${Date.now()}\n`);
228
+ } catch {
229
+ /* the lock file's existence is what matters */
230
+ }
231
+ return fn();
232
+ } finally {
233
+ try {
234
+ fs.closeSync(fd);
235
+ } catch {
236
+ /* ignore */
237
+ }
238
+ try {
239
+ fs.rmSync(lockFile, { force: true });
240
+ } catch {
241
+ /* the stale lock expires on its own */
242
+ }
243
+ }
244
+ }
245
+
246
+ function readEntries(memoryRoot) {
247
+ const file = activityBoardFile(memoryRoot);
248
+ let text = '';
249
+ try {
250
+ text = fs.readFileSync(file, 'utf8');
251
+ } catch (error) {
252
+ if (error?.code === 'ENOENT') return { ok: true, entries: [], bad: 0, exists: false };
253
+ return { ok: false, code: error?.code ?? 'E_READ', entries: [], bad: 0, exists: false };
254
+ }
255
+ const parsed = parseActivityJsonl(text);
256
+ return { ok: true, entries: parsed.entries, bad: parsed.bad, exists: true };
257
+ }
258
+
259
+ function archivedMaxSeq(memoryRoot) {
260
+ let names = [];
261
+ try {
262
+ names = fs.readdirSync(activityBoardDir(memoryRoot));
263
+ } catch {
264
+ return 0;
265
+ }
266
+ const archives = names.filter((name) => ARCHIVE_RE.test(name)).sort().reverse();
267
+ for (const name of archives) {
268
+ const file = path.join(activityBoardDir(memoryRoot), name);
269
+ try {
270
+ const stat = fs.statSync(file);
271
+ const readBytes = Math.min(stat.size, 128 * 1024);
272
+ const fd = fs.openSync(file, 'r');
273
+ const buffer = Buffer.alloc(readBytes);
274
+ try {
275
+ fs.readSync(fd, buffer, 0, readBytes, stat.size - readBytes);
276
+ } finally {
277
+ fs.closeSync(fd);
278
+ }
279
+ let text = buffer.toString('utf8');
280
+ if (readBytes < stat.size) text = text.slice(text.indexOf('\n') + 1);
281
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '');
282
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
283
+ try {
284
+ const row = JSON.parse(lines[index]);
285
+ if (Number.isInteger(row?.seq)) return row.seq;
286
+ } catch {
287
+ /* keep scanning backwards */
288
+ }
289
+ }
290
+ } catch {
291
+ /* try the previous archive month */
292
+ }
293
+ }
294
+ return 0;
295
+ }
296
+
297
+ function nextSeq(entries, memoryRoot, floorSeq = 0) {
298
+ let max = Number.isInteger(floorSeq) && floorSeq > 0 ? floorSeq : 0;
299
+ for (const entry of entries) {
300
+ if (Number.isInteger(entry.seq) && entry.seq > max) max = entry.seq;
301
+ }
302
+ if (max === 0) max = archivedMaxSeq(memoryRoot);
303
+ return max + 1;
304
+ }
305
+
306
+ /** Read the seq high-water sidecar; 0 when absent/corrupt (best effort). */
307
+ export function readSeqHighWater(memoryRoot) {
308
+ const read = readJsonFile(activitySeqHighWaterFile(memoryRoot));
309
+ const value = read.ok ? read.value : null;
310
+ return Number.isInteger(value?.seq) && value.seq > 0 ? value.seq : 0;
311
+ }
312
+
313
+ function persistSeqHighWater(memoryRoot, seq, at) {
314
+ const current = readSeqHighWater(memoryRoot);
315
+ if (seq <= current) return;
316
+ try {
317
+ writeJsonAtomic(activitySeqHighWaterFile(memoryRoot), { v: 1, seq, updatedAt: at });
318
+ } catch {
319
+ /* the board remains correct; only the reset protection degrades */
320
+ }
321
+ }
322
+
323
+ function writeResetMarker(memoryRoot, marker) {
324
+ try {
325
+ writeJsonAtomic(activityResetMarkerFile(memoryRoot), marker);
326
+ } catch {
327
+ /* diagnostics only; the high-water sidecar is what prevents the stranding */
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Pick the row a new activity should merge into. Window = the last 10 live rows
333
+ * of this session; a distilled phase row is not a merge target (otherwise its
334
+ * count could never grow into a new phase). Similarity is checked even when the
335
+ * key differs, matching the spec's "key 相同或文本相似度≥0.8".
336
+ */
337
+ export function selectDedupeTarget(entries, candidate, options = {}) {
338
+ const windowSize = Number.isInteger(options.window) ? options.window : DEDUPE_WINDOW;
339
+ const threshold = Number.isFinite(options.threshold) ? options.threshold : SIMILARITY_THRESHOLD;
340
+ const sessionId = String(candidate?.sessionId ?? '');
341
+ const key = normalizeKey(candidate?.key);
342
+ const text = oneLine(candidate?.text);
343
+ const candidateKind = ACTIVITY_KINDS.includes(candidate?.kind) ? candidate.kind : 'doing';
344
+ const thinking = candidateKind === 'thinking';
345
+ const rows = (Array.isArray(entries) ? entries : []).filter(
346
+ (entry) => entry && entry.sessionId === sessionId && entry.archived !== true,
347
+ );
348
+ const windowRows = rows.slice(-Math.max(1, windowSize));
349
+ let best = null;
350
+ for (const row of windowRows) {
351
+ // 1.8.0: thinking summaries and normal activity rows never merge into each
352
+ // other (different kind = different panel section and different cursors).
353
+ if ((row.kind === 'thinking') !== thinking) continue;
354
+ if (normalizeKey(row.key) === key) return { row, reason: 'key', score: 1 };
355
+ const score = similarity(row.text, text);
356
+ if (score >= threshold && (best === null || score > best.score)) best = { row, reason: 'similarity', score };
357
+ }
358
+ return best;
359
+ }
360
+
361
+ /**
362
+ * Distillation plan for one session+key. Trigger: the newest live row with
363
+ * that key was seen ≥3 times (count), or the key's active rows add up to ≥10.
364
+ * A lone phase row is never re-distilled (it is already the compressed form).
365
+ */
366
+ export function planDistillation(entries, options = {}) {
367
+ const sessionId = String(options.sessionId ?? '');
368
+ const key = normalizeKey(options.key);
369
+ const active = (Array.isArray(entries) ? entries : []).filter(
370
+ (entry) => entry
371
+ && entry.sessionId === sessionId
372
+ && entry.archived !== true
373
+ && normalizeKey(entry.key) === key,
374
+ );
375
+ const normal = active.filter((entry) => entry.phase !== true);
376
+ if (normal.length === 0) return null;
377
+ const total = active.reduce((sum, entry) => sum + countOf(entry), 0);
378
+ const newestNormalCount = Math.max(...normal.map(countOf));
379
+ if (newestNormalCount < DISTILL_MIN_COUNT && total < DISTILL_TOTAL_COUNT) return null;
380
+ const ordered = [...active].sort((left, right) => left.seq - right.seq);
381
+ const last = ordered[ordered.length - 1];
382
+ const kind = last.kind === 'blocked' || last.kind === 'done' ? last.kind : 'finding';
383
+ const detail = last.last_text ?? last.text;
384
+ const summary = {
385
+ schemaVersion: ACTIVITY_SCHEMA_VERSION,
386
+ seq: 0, // filled by append after nextSeq()
387
+ sessionId,
388
+ at: options.at ?? new Date().toISOString(),
389
+ last_at: options.at ?? new Date().toISOString(),
390
+ kind,
391
+ text: distillActivityText(key, total, detail),
392
+ last_text: clip(oneLine(detail), ACTIVITY_TEXT_MAX),
393
+ key,
394
+ norm: sha256Hex(normalizeText(detail)).slice(0, 16),
395
+ dupOf: null,
396
+ count: total,
397
+ phase: true,
398
+ };
399
+ return { rows: active, summary };
400
+ }
401
+
402
+ /**
403
+ * Keep the newest `recentMax` rows in activity.jsonl. Older rows are appended
404
+ * to their local-month archive first; if any archive append fails, nothing is
405
+ * dropped (a slightly long board is better than data loss).
406
+ *
407
+ * Recency is `seq` (a refresh bumps the merged row's seq). The physical array
408
+ * order is NOT trusted: the caller may hand us a board that grew past the cap
409
+ * (a previous archive append failed) where the just-refreshed row still sits at
410
+ * its old physical position. Slicing by array position would archive that row —
411
+ * and then a sibling's seq-only cursor could never see the newest action.
412
+ */
413
+ function archiveOverflow(memoryRoot, entries, recentMax) {
414
+ if (entries.length <= recentMax) return entries;
415
+ const ordered = [...entries].sort((left, right) => (left?.seq ?? 0) - (right?.seq ?? 0));
416
+ const keep = ordered.slice(ordered.length - recentMax);
417
+ const drop = ordered.slice(0, ordered.length - recentMax);
418
+ const groups = new Map();
419
+ for (const row of drop) {
420
+ const month = monthKeyFor(row.at);
421
+ if (!groups.has(month)) groups.set(month, []);
422
+ groups.get(month).push(row);
423
+ }
424
+ try {
425
+ fs.mkdirSync(activityBoardDir(memoryRoot), { recursive: true });
426
+ for (const [month, rows] of groups) {
427
+ fs.appendFileSync(activityArchiveFile(memoryRoot, month), serializeActivityEntries(rows), 'utf8');
428
+ }
429
+ } catch {
430
+ return entries;
431
+ }
432
+ return keep;
433
+ }
434
+
435
+ /**
436
+ * Create the activity store.
437
+ *
438
+ * options: memoryRoot (required), now(), recentMax, privacyScan(text).
439
+ * append() is synchronous and serialized across processes by the lock.
440
+ */
441
+ export function createActivityStore(options = {}) {
442
+ const memoryRoot = typeof options.memoryRoot === 'string' && options.memoryRoot.trim() !== ''
443
+ ? path.resolve(options.memoryRoot)
444
+ : null;
445
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
446
+ const privacyScan = typeof options.privacyScan === 'function' ? options.privacyScan : defaultPrivacyScan;
447
+ const recentMax = Number.isInteger(options.recentMax) && options.recentMax > 0
448
+ ? options.recentMax
449
+ : ACTIVITY_RECENT_MAX;
450
+
451
+ function read() {
452
+ if (!memoryRoot) return { ok: false, code: 'E_NO_ROOT', entries: [], bad: 0, exists: false };
453
+ return readEntries(memoryRoot);
454
+ }
455
+
456
+ /**
457
+ * Append or merge one activity row.
458
+ *
459
+ * options.touchSeq: a merge still carries new information (a tool call moved
460
+ * on, a turn concluded). The default merge keeps seq stable — that is the
461
+ * rc.1 dedupe contract and the injection cursor was seq-only, which meant a
462
+ * sibling session saw the first row of a long turn and then nothing. With
463
+ * `touchSeq` the merged row takes `nextSeq()`, so every real refresh is
464
+ * strictly newer for `seq > lastSeenSeq`.
465
+ * options.refreshText: replace the whole row text with the newest occurrence
466
+ * (event-level rows show the current action) instead of the default 「首条
467
+ * 正文保留」 rule.
468
+ *
469
+ * Returns {ok, action:'appended'|'merged'|'refreshed'|'distilled', entry, seq,
470
+ * mergedSeq, seqBumped, archivedCount, streamReset, code}.
471
+ *
472
+ * Sequel monotonicity (RE-R15.5): every successful write persists the max seq
473
+ * seen to `<board>/.activity.seq`, and new rows start above max(board, archive,
474
+ * high-water). Deleting/emptying the board therefore does NOT restart seq at 1,
475
+ * so a sibling's high `lastSeenSeq` cursor keeps working. If a reset is detected
476
+ * (board missing/empty but a previous stream is known), a `stream.reset.json`
477
+ * marker is written for diagnostics / consumer fallback.
478
+ */
479
+ function append(input = {}, options = {}) {
480
+ if (!memoryRoot) return { ok: false, code: 'E_NO_ROOT' };
481
+ const touchSeq = options.touchSeq === true;
482
+ const refreshText = options.refreshText === true;
483
+ const sessionId = String(input.sessionId ?? '').trim().toLowerCase();
484
+ if (!ACTIVITY_SESSION_RE.test(sessionId)) return { ok: false, code: 'E_SESSION_ID' };
485
+ const kind = ACTIVITY_KINDS.includes(input.kind) ? input.kind : 'doing';
486
+ const textMax = kind === 'thinking' ? ACTIVITY_THINKING_TEXT_MAX : ACTIVITY_TEXT_MAX;
487
+ const text = clip(oneLine(input.text), textMax);
488
+ if (text === '') return { ok: false, code: 'E_EMPTY_TEXT' };
489
+ const key = normalizeKey(input.key);
490
+ const atDate = new Date(input.at ?? now());
491
+ const at = Number.isFinite(atDate.getTime()) ? atDate.toISOString() : new Date().toISOString();
492
+
493
+ // Reuse the privacy gate before anything touches disk: a local_only verdict
494
+ // drops the row (board data is shared across sessions).
495
+ try {
496
+ const verdict = privacyScan(`${text}\n${key}`);
497
+ if (!verdict || verdict.verdict === 'local_only') {
498
+ return { ok: false, code: 'E_PRIVACY', skipped: true };
499
+ }
500
+ } catch {
501
+ return { ok: false, code: 'E_PRIVACY', skipped: true };
502
+ }
503
+
504
+ return withLock(memoryRoot, () => {
505
+ const current = readEntries(memoryRoot);
506
+ if (!current.ok) return { ok: false, code: current.code };
507
+ let entries = current.entries;
508
+ // Seq floor: never go below the persisted high-water mark (board deletion
509
+ // or compaction must not rewind seq for sibling cursors).
510
+ const highWater = readSeqHighWater(memoryRoot);
511
+ const seqFor = (list) => nextSeq(list, memoryRoot, highWater);
512
+ let streamReset = null;
513
+ if (entries.length === 0) {
514
+ const archivedMax = archivedMaxSeq(memoryRoot);
515
+ const previous = Math.max(highWater, archivedMax);
516
+ if (previous > 0) {
517
+ streamReset = {
518
+ v: 1,
519
+ at,
520
+ reason: current.exists === false ? 'board-missing' : 'board-empty',
521
+ previousHighWater: highWater,
522
+ archivedMax,
523
+ };
524
+ }
525
+ }
526
+ const target = selectDedupeTarget(entries, { sessionId, key, text, kind });
527
+ let action = 'appended';
528
+ let entry = null;
529
+ let mergedSeq = null;
530
+ let seqBumped = false;
531
+ let archivedCount = 0;
532
+
533
+ if (target) {
534
+ const row = target.row;
535
+ const previousSeq = row.seq;
536
+ row.count = countOf(row) + 1;
537
+ row.last_at = at;
538
+ // The first text is kept for a normal row (spec). A phase row is the
539
+ // generated compression, so it is refreshed with the newest occurrence:
540
+ // 「阶段:…(N 次,最近 …)」 must show the current N and detail.
541
+ row.kind = kind;
542
+ if (kind === 'thinking') {
543
+ if (Number.isInteger(input.turnRef)) row.turnRef = input.turnRef;
544
+ row.charCount = Number.isInteger(input.charCount) ? input.charCount : charLength(text);
545
+ }
546
+ if (row.phase === true) {
547
+ row.last_text = text;
548
+ row.text = distillActivityText(row.key, row.count, text);
549
+ row.norm = sha256Hex(normalizeText(text)).slice(0, 16);
550
+ } else if (refreshText) {
551
+ // Event-level rows track the current action instead of the first one.
552
+ row.text = text;
553
+ row.norm = sha256Hex(normalizeText(text)).slice(0, 16);
554
+ }
555
+ if (touchSeq) {
556
+ // A refresh must be newer than the reader's lastSeenSeq; bump before
557
+ // the distillation plan so the phase row still gets a larger seq.
558
+ row.seq = seqFor(entries);
559
+ seqBumped = true;
560
+ // Keep 「array order tracks recency」 for callers that slice the array
561
+ // directly (archive overflow) — the row was just refreshed, so it
562
+ // must not stay parked at its old physical position.
563
+ const parkedAt = entries.indexOf(row);
564
+ if (parkedAt >= 0 && parkedAt !== entries.length - 1) {
565
+ entries.splice(parkedAt, 1);
566
+ entries.push(row);
567
+ }
568
+ }
569
+ entry = row;
570
+ mergedSeq = previousSeq;
571
+ action = touchSeq ? 'refreshed' : 'merged';
572
+ } else {
573
+ entry = {
574
+ schemaVersion: ACTIVITY_SCHEMA_VERSION,
575
+ seq: seqFor(entries),
576
+ sessionId,
577
+ at,
578
+ last_at: at,
579
+ kind,
580
+ text,
581
+ key,
582
+ norm: sha256Hex(normalizeText(text)).slice(0, 16),
583
+ dupOf: null,
584
+ count: 1,
585
+ };
586
+ // Thinking summaries carry their source turn and size for machine
587
+ // consumers (the injection line shows 「第 N 轮」); normal rows omit them.
588
+ if (kind === 'thinking') {
589
+ if (Number.isInteger(input.turnRef)) entry.turnRef = input.turnRef;
590
+ if (Number.isInteger(input.charCount)) entry.charCount = input.charCount;
591
+ else entry.charCount = charLength(text);
592
+ }
593
+ entries.push(entry);
594
+ action = 'appended';
595
+ }
596
+
597
+ const plan = entry?.phase === true ? null : planDistillation(entries, { sessionId, key: entry.key, at });
598
+ if (plan) {
599
+ for (const row of plan.rows) row.archived = true;
600
+ plan.summary.seq = seqFor(entries);
601
+ entries.push(plan.summary);
602
+ archivedCount = plan.rows.length;
603
+ entry = plan.summary;
604
+ action = 'distilled';
605
+ }
606
+
607
+ entries = archiveOverflow(memoryRoot, entries, recentMax);
608
+ const written = writeTextDurable(activityBoardFile(memoryRoot), serializeActivityEntries(entries));
609
+ if (!written.ok) return { ok: false, code: 'E_WRITE', message: written.error };
610
+ // High-water sidecar: keeps seq monotonic even if this board is deleted or
611
+ // truncated between now and the next write (RE-R15.5).
612
+ const boardMax = entries.reduce(
613
+ (max, row) => (Number.isInteger(row.seq) && row.seq > max ? row.seq : max),
614
+ Number.isInteger(entry?.seq) ? entry.seq : 0,
615
+ );
616
+ persistSeqHighWater(memoryRoot, boardMax, at);
617
+ if (streamReset) {
618
+ writeResetMarker(memoryRoot, { ...streamReset, boardMax, firstSeq: entry.seq });
619
+ }
620
+ return {
621
+ ok: true,
622
+ action,
623
+ entry,
624
+ seq: entry.seq,
625
+ mergedSeq,
626
+ seqBumped,
627
+ archivedCount,
628
+ streamReset: streamReset !== null,
629
+ recent: entries.length,
630
+ };
631
+ });
632
+ }
633
+
634
+ return {
635
+ memoryRoot,
636
+ boardFile: memoryRoot ? activityBoardFile(memoryRoot) : null,
637
+ recentMax,
638
+ read,
639
+ append,
640
+ highWater: () => readSeqHighWater(memoryRoot),
641
+ resetMarkerFile: memoryRoot ? activityResetMarkerFile(memoryRoot) : null,
642
+ nextSeq: (entries) => nextSeq(entries ?? read().entries ?? [], memoryRoot, readSeqHighWater(memoryRoot)),
643
+ };
644
+ }