@jjh32456/dsh-longlive 1.6.7 → 1.6.8

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 (41) hide show
  1. package/CHANGELOG.md +40 -8
  2. package/QUICKSTART.md +10 -10
  3. package/README.en.md +4 -3
  4. package/README.md +10 -4
  5. package/SPEC.md +2 -2
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +3 -2
  8. package/docs/CONFIG.md +33 -6
  9. package/docs/DEV.md +3 -3
  10. package/docs/FAQ.md +4 -4
  11. package/docs/FEATURES.md +36 -5
  12. package/docs/OPERATIONS.md +1 -1
  13. package/docs/QUICKSTART.md +3 -3
  14. package/docs/README.en.md +3 -2
  15. package/docs/TROUBLESHOOTING.md +3 -3
  16. package/package.json +13 -8
  17. package/src/activity/index.js +649 -0
  18. package/src/activity/model.js +341 -0
  19. package/src/activity/store.js +625 -0
  20. package/src/config.js +29 -2
  21. package/src/features.js +26 -0
  22. package/src/heartbeat.js +156 -23
  23. package/src/identity/handover.js +52 -1
  24. package/src/live.js +22 -2
  25. package/src/recall/privacy.mjs +105 -16
  26. package/src/router/service/board.mjs +95 -15
  27. package/src/router/service/hint.mjs +14 -0
  28. package/src/router/service/service.mjs +27 -11
  29. package/test/activity-realtime.test.mjs +959 -0
  30. package/test/activity-stream.test.mjs +512 -0
  31. package/test/autobind.test.mjs +25 -11
  32. package/test/config-schema.test.mjs +6 -0
  33. package/test/feature-flags.test.mjs +17 -0
  34. package/test/fixtures/activity-append-child.mjs +29 -0
  35. package/test/live.test.mjs +6 -3
  36. package/test/packaging.test.mjs +7 -5
  37. package/test/router-background/router.test.mjs +44 -12
  38. package/test/run-rc.mjs +16 -0
  39. package/test/safety-defaults.test.mjs +5 -3
  40. package/test/state-truth.test.mjs +937 -0
  41. package/test/version-consistency.test.mjs +286 -0
@@ -0,0 +1,625 @@
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
+ DEDUPE_WINDOW,
26
+ DISTILL_MIN_COUNT,
27
+ DISTILL_TOTAL_COUNT,
28
+ SIMILARITY_THRESHOLD,
29
+ clip,
30
+ distillActivityText,
31
+ normalizeKey,
32
+ normalizeText,
33
+ oneLine,
34
+ similarity,
35
+ } from './model.js';
36
+
37
+ export const ACTIVITY_SCHEMA_VERSION = 1;
38
+ export const ACTIVITY_BOARD_DIR = 'board';
39
+ export const ACTIVITY_FILE_NAME = 'activity.jsonl';
40
+ export const ACTIVITY_RECENT_MAX = 500;
41
+ export const ACTIVITY_LOCK_STALE_MS = 10000;
42
+ export const ACTIVITY_LOCK_TIMEOUT_MS = 2000;
43
+ export const ACTIVITY_SESSION_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
44
+ /** Monotonic seq high-water mark: survives board deletion/compaction. */
45
+ export const ACTIVITY_SEQ_HIGH_WATER_FILE = '.activity.seq';
46
+ /** Written when the store detects the board was reset (deleted/emptied). */
47
+ export const ACTIVITY_RESET_MARKER_FILE = 'stream.reset.json';
48
+
49
+ const ARCHIVE_RE = /^activity-(\d{6})\.jsonl$/;
50
+
51
+ export function activityBoardDir(memoryRoot) {
52
+ return path.join(String(memoryRoot ?? ''), '.longlive', ACTIVITY_BOARD_DIR);
53
+ }
54
+
55
+ export function activityBoardFile(memoryRoot) {
56
+ return path.join(activityBoardDir(memoryRoot), ACTIVITY_FILE_NAME);
57
+ }
58
+
59
+ /** `<board>/.activity.seq` — the seq high-water sidecar (RE-R15.5). */
60
+ export function activitySeqHighWaterFile(memoryRoot) {
61
+ return path.join(activityBoardDir(memoryRoot), ACTIVITY_SEQ_HIGH_WATER_FILE);
62
+ }
63
+
64
+ /** `<board>/stream.reset.json` — last detected board-reset evidence (RE-R15.5). */
65
+ export function activityResetMarkerFile(memoryRoot) {
66
+ return path.join(activityBoardDir(memoryRoot), ACTIVITY_RESET_MARKER_FILE);
67
+ }
68
+
69
+ export function activityArchiveFile(memoryRoot, monthKey) {
70
+ return path.join(activityBoardDir(memoryRoot), `activity-${monthKey}.jsonl`);
71
+ }
72
+
73
+ export function monthKeyFor(value) {
74
+ const date = value instanceof Date ? value : new Date(value ?? Date.now());
75
+ if (!Number.isFinite(date.getTime())) return '000000';
76
+ const year = date.getFullYear();
77
+ const month = String(date.getMonth() + 1).padStart(2, '0');
78
+ return `${year}${month}`;
79
+ }
80
+
81
+ export function sha256Hex(value) {
82
+ return crypto.createHash('sha256').update(String(value ?? ''), 'utf8').digest('hex');
83
+ }
84
+
85
+ function countOf(entry) {
86
+ const count = Number(entry?.count);
87
+ return Number.isInteger(count) && count > 0 ? count : 1;
88
+ }
89
+
90
+ function isObject(value) {
91
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
92
+ }
93
+
94
+ /** Parse JSONL defensively; bad lines are counted, never thrown. */
95
+ export function parseActivityJsonl(text) {
96
+ const entries = [];
97
+ let bad = 0;
98
+ for (const line of String(text ?? '').replace(/^\uFEFF/, '').split(/\r?\n/)) {
99
+ if (line.trim() === '') continue;
100
+ let row = null;
101
+ try {
102
+ row = JSON.parse(line);
103
+ } catch {
104
+ bad += 1;
105
+ continue;
106
+ }
107
+ if (!isObject(row) || !Number.isInteger(row.seq) || typeof row.text !== 'string' || typeof row.sessionId !== 'string') {
108
+ bad += 1;
109
+ continue;
110
+ }
111
+ entries.push(row);
112
+ }
113
+ entries.sort((left, right) => left.seq - right.seq);
114
+ return { entries, bad };
115
+ }
116
+
117
+ export function serializeActivityEntries(entries) {
118
+ return entries.map((entry) => JSON.stringify(entry)).join('\n') + (entries.length > 0 ? '\n' : '');
119
+ }
120
+
121
+ function sleepSync(ms) {
122
+ try {
123
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
124
+ } catch {
125
+ /* fall through: a busy loop is still bounded by the deadline */
126
+ }
127
+ }
128
+
129
+ function writeTextDurable(file, text) {
130
+ const dir = path.dirname(file);
131
+ fs.mkdirSync(dir, { recursive: true });
132
+ const tmp = path.join(dir, `.${path.basename(file)}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`);
133
+ const fd = fs.openSync(tmp, 'w');
134
+ try {
135
+ fs.writeFileSync(fd, text, 'utf8');
136
+ try {
137
+ fs.fsyncSync(fd);
138
+ } catch {
139
+ /* fsync is best effort on some filesystems */
140
+ }
141
+ } finally {
142
+ try {
143
+ fs.closeSync(fd);
144
+ } catch {
145
+ /* ignore */
146
+ }
147
+ }
148
+ let lastError = null;
149
+ for (let attempt = 0; attempt < 5; attempt += 1) {
150
+ try {
151
+ const readBack = fs.readFileSync(tmp, 'utf8');
152
+ if (readBack !== text) throw new Error('activity write read-back mismatch');
153
+ fs.renameSync(tmp, file);
154
+ return { ok: true };
155
+ } catch (error) {
156
+ lastError = error;
157
+ if (attempt < 4) sleepSync(10 * (attempt + 1));
158
+ }
159
+ }
160
+ try {
161
+ fs.rmSync(tmp, { force: true });
162
+ } catch {
163
+ /* ignore */
164
+ }
165
+ return { ok: false, error: lastError?.code ?? lastError?.message ?? 'E_WRITE' };
166
+ }
167
+
168
+ /** Atomic JSON write used by the per-session lastSeenSeq state. */
169
+ export function writeJsonAtomic(file, value) {
170
+ const text = `${JSON.stringify(value, null, 2)}\n`;
171
+ try {
172
+ const written = writeTextDurable(file, text);
173
+ return written.ok ? { ok: true, file, bytes: Buffer.byteLength(text, 'utf8') } : written;
174
+ } catch (error) {
175
+ return { ok: false, file, error: error?.code ?? error?.message ?? 'E_WRITE' };
176
+ }
177
+ }
178
+
179
+ export function readJsonFile(file) {
180
+ try {
181
+ return { ok: true, value: JSON.parse(fs.readFileSync(file, 'utf8')) };
182
+ } catch (error) {
183
+ return { ok: false, error: error?.code ?? 'E_READ' };
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Write/read the whole recent board under `<board>/.activity.lock`. Returns
189
+ * `{ok:false,code:'E_LOCK_TIMEOUT'}` instead of blocking a turn forever.
190
+ */
191
+ function withLock(memoryRoot, fn) {
192
+ const dir = activityBoardDir(memoryRoot);
193
+ try {
194
+ fs.mkdirSync(dir, { recursive: true });
195
+ } catch (error) {
196
+ return { ok: false, code: 'E_MKDIR', message: error?.message ?? String(error) };
197
+ }
198
+ const lockFile = path.join(dir, '.activity.lock');
199
+ const started = Date.now();
200
+ let fd = null;
201
+ while (fd === null) {
202
+ try {
203
+ fd = fs.openSync(lockFile, 'wx');
204
+ } catch (error) {
205
+ if (error?.code !== 'EEXIST') {
206
+ return { ok: false, code: 'E_LOCK', message: error?.code ?? String(error) };
207
+ }
208
+ try {
209
+ const stat = fs.statSync(lockFile);
210
+ if (Date.now() - stat.mtimeMs > ACTIVITY_LOCK_STALE_MS) {
211
+ fs.rmSync(lockFile, { force: true });
212
+ continue;
213
+ }
214
+ } catch {
215
+ /* the other writer may have released it between open and stat */
216
+ }
217
+ if (Date.now() - started > ACTIVITY_LOCK_TIMEOUT_MS) {
218
+ return { ok: false, code: 'E_LOCK_TIMEOUT' };
219
+ }
220
+ sleepSync(10);
221
+ }
222
+ }
223
+ try {
224
+ try {
225
+ fs.writeSync(fd, `${process.pid} ${Date.now()}\n`);
226
+ } catch {
227
+ /* the lock file's existence is what matters */
228
+ }
229
+ return fn();
230
+ } finally {
231
+ try {
232
+ fs.closeSync(fd);
233
+ } catch {
234
+ /* ignore */
235
+ }
236
+ try {
237
+ fs.rmSync(lockFile, { force: true });
238
+ } catch {
239
+ /* the stale lock expires on its own */
240
+ }
241
+ }
242
+ }
243
+
244
+ function readEntries(memoryRoot) {
245
+ const file = activityBoardFile(memoryRoot);
246
+ let text = '';
247
+ try {
248
+ text = fs.readFileSync(file, 'utf8');
249
+ } catch (error) {
250
+ if (error?.code === 'ENOENT') return { ok: true, entries: [], bad: 0, exists: false };
251
+ return { ok: false, code: error?.code ?? 'E_READ', entries: [], bad: 0, exists: false };
252
+ }
253
+ const parsed = parseActivityJsonl(text);
254
+ return { ok: true, entries: parsed.entries, bad: parsed.bad, exists: true };
255
+ }
256
+
257
+ function archivedMaxSeq(memoryRoot) {
258
+ let names = [];
259
+ try {
260
+ names = fs.readdirSync(activityBoardDir(memoryRoot));
261
+ } catch {
262
+ return 0;
263
+ }
264
+ const archives = names.filter((name) => ARCHIVE_RE.test(name)).sort().reverse();
265
+ for (const name of archives) {
266
+ const file = path.join(activityBoardDir(memoryRoot), name);
267
+ try {
268
+ const stat = fs.statSync(file);
269
+ const readBytes = Math.min(stat.size, 128 * 1024);
270
+ const fd = fs.openSync(file, 'r');
271
+ const buffer = Buffer.alloc(readBytes);
272
+ try {
273
+ fs.readSync(fd, buffer, 0, readBytes, stat.size - readBytes);
274
+ } finally {
275
+ fs.closeSync(fd);
276
+ }
277
+ let text = buffer.toString('utf8');
278
+ if (readBytes < stat.size) text = text.slice(text.indexOf('\n') + 1);
279
+ const lines = text.split(/\r?\n/).filter((line) => line.trim() !== '');
280
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
281
+ try {
282
+ const row = JSON.parse(lines[index]);
283
+ if (Number.isInteger(row?.seq)) return row.seq;
284
+ } catch {
285
+ /* keep scanning backwards */
286
+ }
287
+ }
288
+ } catch {
289
+ /* try the previous archive month */
290
+ }
291
+ }
292
+ return 0;
293
+ }
294
+
295
+ function nextSeq(entries, memoryRoot, floorSeq = 0) {
296
+ let max = Number.isInteger(floorSeq) && floorSeq > 0 ? floorSeq : 0;
297
+ for (const entry of entries) {
298
+ if (Number.isInteger(entry.seq) && entry.seq > max) max = entry.seq;
299
+ }
300
+ if (max === 0) max = archivedMaxSeq(memoryRoot);
301
+ return max + 1;
302
+ }
303
+
304
+ /** Read the seq high-water sidecar; 0 when absent/corrupt (best effort). */
305
+ export function readSeqHighWater(memoryRoot) {
306
+ const read = readJsonFile(activitySeqHighWaterFile(memoryRoot));
307
+ const value = read.ok ? read.value : null;
308
+ return Number.isInteger(value?.seq) && value.seq > 0 ? value.seq : 0;
309
+ }
310
+
311
+ function persistSeqHighWater(memoryRoot, seq, at) {
312
+ const current = readSeqHighWater(memoryRoot);
313
+ if (seq <= current) return;
314
+ try {
315
+ writeJsonAtomic(activitySeqHighWaterFile(memoryRoot), { v: 1, seq, updatedAt: at });
316
+ } catch {
317
+ /* the board remains correct; only the reset protection degrades */
318
+ }
319
+ }
320
+
321
+ function writeResetMarker(memoryRoot, marker) {
322
+ try {
323
+ writeJsonAtomic(activityResetMarkerFile(memoryRoot), marker);
324
+ } catch {
325
+ /* diagnostics only; the high-water sidecar is what prevents the stranding */
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Pick the row a new activity should merge into. Window = the last 10 live rows
331
+ * of this session; a distilled phase row is not a merge target (otherwise its
332
+ * count could never grow into a new phase). Similarity is checked even when the
333
+ * key differs, matching the spec's "key 相同或文本相似度≥0.8".
334
+ */
335
+ export function selectDedupeTarget(entries, candidate, options = {}) {
336
+ const windowSize = Number.isInteger(options.window) ? options.window : DEDUPE_WINDOW;
337
+ const threshold = Number.isFinite(options.threshold) ? options.threshold : SIMILARITY_THRESHOLD;
338
+ const sessionId = String(candidate?.sessionId ?? '');
339
+ const key = normalizeKey(candidate?.key);
340
+ const text = oneLine(candidate?.text);
341
+ const rows = (Array.isArray(entries) ? entries : []).filter(
342
+ (entry) => entry && entry.sessionId === sessionId && entry.archived !== true,
343
+ );
344
+ const windowRows = rows.slice(-Math.max(1, windowSize));
345
+ let best = null;
346
+ for (const row of windowRows) {
347
+ if (normalizeKey(row.key) === key) return { row, reason: 'key', score: 1 };
348
+ const score = similarity(row.text, text);
349
+ if (score >= threshold && (best === null || score > best.score)) best = { row, reason: 'similarity', score };
350
+ }
351
+ return best;
352
+ }
353
+
354
+ /**
355
+ * Distillation plan for one session+key. Trigger: the newest live row with
356
+ * that key was seen ≥3 times (count), or the key's active rows add up to ≥10.
357
+ * A lone phase row is never re-distilled (it is already the compressed form).
358
+ */
359
+ export function planDistillation(entries, options = {}) {
360
+ const sessionId = String(options.sessionId ?? '');
361
+ const key = normalizeKey(options.key);
362
+ const active = (Array.isArray(entries) ? entries : []).filter(
363
+ (entry) => entry
364
+ && entry.sessionId === sessionId
365
+ && entry.archived !== true
366
+ && normalizeKey(entry.key) === key,
367
+ );
368
+ const normal = active.filter((entry) => entry.phase !== true);
369
+ if (normal.length === 0) return null;
370
+ const total = active.reduce((sum, entry) => sum + countOf(entry), 0);
371
+ const newestNormalCount = Math.max(...normal.map(countOf));
372
+ if (newestNormalCount < DISTILL_MIN_COUNT && total < DISTILL_TOTAL_COUNT) return null;
373
+ const ordered = [...active].sort((left, right) => left.seq - right.seq);
374
+ const last = ordered[ordered.length - 1];
375
+ const kind = last.kind === 'blocked' || last.kind === 'done' ? last.kind : 'finding';
376
+ const detail = last.last_text ?? last.text;
377
+ const summary = {
378
+ schemaVersion: ACTIVITY_SCHEMA_VERSION,
379
+ seq: 0, // filled by append after nextSeq()
380
+ sessionId,
381
+ at: options.at ?? new Date().toISOString(),
382
+ last_at: options.at ?? new Date().toISOString(),
383
+ kind,
384
+ text: distillActivityText(key, total, detail),
385
+ last_text: clip(oneLine(detail), ACTIVITY_TEXT_MAX),
386
+ key,
387
+ norm: sha256Hex(normalizeText(detail)).slice(0, 16),
388
+ dupOf: null,
389
+ count: total,
390
+ phase: true,
391
+ };
392
+ return { rows: active, summary };
393
+ }
394
+
395
+ /**
396
+ * Keep the newest `recentMax` rows in activity.jsonl. Older rows are appended
397
+ * to their local-month archive first; if any archive append fails, nothing is
398
+ * dropped (a slightly long board is better than data loss).
399
+ *
400
+ * Recency is `seq` (a refresh bumps the merged row's seq). The physical array
401
+ * order is NOT trusted: the caller may hand us a board that grew past the cap
402
+ * (a previous archive append failed) where the just-refreshed row still sits at
403
+ * its old physical position. Slicing by array position would archive that row —
404
+ * and then a sibling's seq-only cursor could never see the newest action.
405
+ */
406
+ function archiveOverflow(memoryRoot, entries, recentMax) {
407
+ if (entries.length <= recentMax) return entries;
408
+ const ordered = [...entries].sort((left, right) => (left?.seq ?? 0) - (right?.seq ?? 0));
409
+ const keep = ordered.slice(ordered.length - recentMax);
410
+ const drop = ordered.slice(0, ordered.length - recentMax);
411
+ const groups = new Map();
412
+ for (const row of drop) {
413
+ const month = monthKeyFor(row.at);
414
+ if (!groups.has(month)) groups.set(month, []);
415
+ groups.get(month).push(row);
416
+ }
417
+ try {
418
+ fs.mkdirSync(activityBoardDir(memoryRoot), { recursive: true });
419
+ for (const [month, rows] of groups) {
420
+ fs.appendFileSync(activityArchiveFile(memoryRoot, month), serializeActivityEntries(rows), 'utf8');
421
+ }
422
+ } catch {
423
+ return entries;
424
+ }
425
+ return keep;
426
+ }
427
+
428
+ /**
429
+ * Create the activity store.
430
+ *
431
+ * options: memoryRoot (required), now(), recentMax, privacyScan(text).
432
+ * append() is synchronous and serialized across processes by the lock.
433
+ */
434
+ export function createActivityStore(options = {}) {
435
+ const memoryRoot = typeof options.memoryRoot === 'string' && options.memoryRoot.trim() !== ''
436
+ ? path.resolve(options.memoryRoot)
437
+ : null;
438
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
439
+ const privacyScan = typeof options.privacyScan === 'function' ? options.privacyScan : defaultPrivacyScan;
440
+ const recentMax = Number.isInteger(options.recentMax) && options.recentMax > 0
441
+ ? options.recentMax
442
+ : ACTIVITY_RECENT_MAX;
443
+
444
+ function read() {
445
+ if (!memoryRoot) return { ok: false, code: 'E_NO_ROOT', entries: [], bad: 0, exists: false };
446
+ return readEntries(memoryRoot);
447
+ }
448
+
449
+ /**
450
+ * Append or merge one activity row.
451
+ *
452
+ * options.touchSeq: a merge still carries new information (a tool call moved
453
+ * on, a turn concluded). The default merge keeps seq stable — that is the
454
+ * rc.1 dedupe contract and the injection cursor was seq-only, which meant a
455
+ * sibling session saw the first row of a long turn and then nothing. With
456
+ * `touchSeq` the merged row takes `nextSeq()`, so every real refresh is
457
+ * strictly newer for `seq > lastSeenSeq`.
458
+ * options.refreshText: replace the whole row text with the newest occurrence
459
+ * (event-level rows show the current action) instead of the default 「首条
460
+ * 正文保留」 rule.
461
+ *
462
+ * Returns {ok, action:'appended'|'merged'|'refreshed'|'distilled', entry, seq,
463
+ * mergedSeq, seqBumped, archivedCount, streamReset, code}.
464
+ *
465
+ * Sequel monotonicity (RE-R15.5): every successful write persists the max seq
466
+ * seen to `<board>/.activity.seq`, and new rows start above max(board, archive,
467
+ * high-water). Deleting/emptying the board therefore does NOT restart seq at 1,
468
+ * so a sibling's high `lastSeenSeq` cursor keeps working. If a reset is detected
469
+ * (board missing/empty but a previous stream is known), a `stream.reset.json`
470
+ * marker is written for diagnostics / consumer fallback.
471
+ */
472
+ function append(input = {}, options = {}) {
473
+ if (!memoryRoot) return { ok: false, code: 'E_NO_ROOT' };
474
+ const touchSeq = options.touchSeq === true;
475
+ const refreshText = options.refreshText === true;
476
+ const sessionId = String(input.sessionId ?? '').trim().toLowerCase();
477
+ if (!ACTIVITY_SESSION_RE.test(sessionId)) return { ok: false, code: 'E_SESSION_ID' };
478
+ const text = clip(oneLine(input.text), ACTIVITY_TEXT_MAX);
479
+ if (text === '') return { ok: false, code: 'E_EMPTY_TEXT' };
480
+ const kind = ACTIVITY_KINDS.includes(input.kind) ? input.kind : 'doing';
481
+ const key = normalizeKey(input.key);
482
+ const atDate = new Date(input.at ?? now());
483
+ const at = Number.isFinite(atDate.getTime()) ? atDate.toISOString() : new Date().toISOString();
484
+
485
+ // Reuse the privacy gate before anything touches disk: a local_only verdict
486
+ // drops the row (board data is shared across sessions).
487
+ try {
488
+ const verdict = privacyScan(`${text}\n${key}`);
489
+ if (!verdict || verdict.verdict === 'local_only') {
490
+ return { ok: false, code: 'E_PRIVACY', skipped: true };
491
+ }
492
+ } catch {
493
+ return { ok: false, code: 'E_PRIVACY', skipped: true };
494
+ }
495
+
496
+ return withLock(memoryRoot, () => {
497
+ const current = readEntries(memoryRoot);
498
+ if (!current.ok) return { ok: false, code: current.code };
499
+ let entries = current.entries;
500
+ // Seq floor: never go below the persisted high-water mark (board deletion
501
+ // or compaction must not rewind seq for sibling cursors).
502
+ const highWater = readSeqHighWater(memoryRoot);
503
+ const seqFor = (list) => nextSeq(list, memoryRoot, highWater);
504
+ let streamReset = null;
505
+ if (entries.length === 0) {
506
+ const archivedMax = archivedMaxSeq(memoryRoot);
507
+ const previous = Math.max(highWater, archivedMax);
508
+ if (previous > 0) {
509
+ streamReset = {
510
+ v: 1,
511
+ at,
512
+ reason: current.exists === false ? 'board-missing' : 'board-empty',
513
+ previousHighWater: highWater,
514
+ archivedMax,
515
+ };
516
+ }
517
+ }
518
+ const target = selectDedupeTarget(entries, { sessionId, key, text });
519
+ let action = 'appended';
520
+ let entry = null;
521
+ let mergedSeq = null;
522
+ let seqBumped = false;
523
+ let archivedCount = 0;
524
+
525
+ if (target) {
526
+ const row = target.row;
527
+ const previousSeq = row.seq;
528
+ row.count = countOf(row) + 1;
529
+ row.last_at = at;
530
+ // The first text is kept for a normal row (spec). A phase row is the
531
+ // generated compression, so it is refreshed with the newest occurrence:
532
+ // 「阶段:…(N 次,最近 …)」 must show the current N and detail.
533
+ row.kind = kind;
534
+ if (row.phase === true) {
535
+ row.last_text = text;
536
+ row.text = distillActivityText(row.key, row.count, text);
537
+ row.norm = sha256Hex(normalizeText(text)).slice(0, 16);
538
+ } else if (refreshText) {
539
+ // Event-level rows track the current action instead of the first one.
540
+ row.text = text;
541
+ row.norm = sha256Hex(normalizeText(text)).slice(0, 16);
542
+ }
543
+ if (touchSeq) {
544
+ // A refresh must be newer than the reader's lastSeenSeq; bump before
545
+ // the distillation plan so the phase row still gets a larger seq.
546
+ row.seq = seqFor(entries);
547
+ seqBumped = true;
548
+ // Keep 「array order tracks recency」 for callers that slice the array
549
+ // directly (archive overflow) — the row was just refreshed, so it
550
+ // must not stay parked at its old physical position.
551
+ const parkedAt = entries.indexOf(row);
552
+ if (parkedAt >= 0 && parkedAt !== entries.length - 1) {
553
+ entries.splice(parkedAt, 1);
554
+ entries.push(row);
555
+ }
556
+ }
557
+ entry = row;
558
+ mergedSeq = previousSeq;
559
+ action = touchSeq ? 'refreshed' : 'merged';
560
+ } else {
561
+ entry = {
562
+ schemaVersion: ACTIVITY_SCHEMA_VERSION,
563
+ seq: seqFor(entries),
564
+ sessionId,
565
+ at,
566
+ last_at: at,
567
+ kind,
568
+ text,
569
+ key,
570
+ norm: sha256Hex(normalizeText(text)).slice(0, 16),
571
+ dupOf: null,
572
+ count: 1,
573
+ };
574
+ entries.push(entry);
575
+ action = 'appended';
576
+ }
577
+
578
+ const plan = entry?.phase === true ? null : planDistillation(entries, { sessionId, key: entry.key, at });
579
+ if (plan) {
580
+ for (const row of plan.rows) row.archived = true;
581
+ plan.summary.seq = seqFor(entries);
582
+ entries.push(plan.summary);
583
+ archivedCount = plan.rows.length;
584
+ entry = plan.summary;
585
+ action = 'distilled';
586
+ }
587
+
588
+ entries = archiveOverflow(memoryRoot, entries, recentMax);
589
+ const written = writeTextDurable(activityBoardFile(memoryRoot), serializeActivityEntries(entries));
590
+ if (!written.ok) return { ok: false, code: 'E_WRITE', message: written.error };
591
+ // High-water sidecar: keeps seq monotonic even if this board is deleted or
592
+ // truncated between now and the next write (RE-R15.5).
593
+ const boardMax = entries.reduce(
594
+ (max, row) => (Number.isInteger(row.seq) && row.seq > max ? row.seq : max),
595
+ Number.isInteger(entry?.seq) ? entry.seq : 0,
596
+ );
597
+ persistSeqHighWater(memoryRoot, boardMax, at);
598
+ if (streamReset) {
599
+ writeResetMarker(memoryRoot, { ...streamReset, boardMax, firstSeq: entry.seq });
600
+ }
601
+ return {
602
+ ok: true,
603
+ action,
604
+ entry,
605
+ seq: entry.seq,
606
+ mergedSeq,
607
+ seqBumped,
608
+ archivedCount,
609
+ streamReset: streamReset !== null,
610
+ recent: entries.length,
611
+ };
612
+ });
613
+ }
614
+
615
+ return {
616
+ memoryRoot,
617
+ boardFile: memoryRoot ? activityBoardFile(memoryRoot) : null,
618
+ recentMax,
619
+ read,
620
+ append,
621
+ highWater: () => readSeqHighWater(memoryRoot),
622
+ resetMarkerFile: memoryRoot ? activityResetMarkerFile(memoryRoot) : null,
623
+ nextSeq: (entries) => nextSeq(entries ?? read().entries ?? [], memoryRoot, readSeqHighWater(memoryRoot)),
624
+ };
625
+ }
package/src/config.js CHANGED
@@ -54,7 +54,7 @@ const ZH = {
54
54
  recoverOnStart: '启动时收尾或回滚未完成的可见截断事务(默认开启)。',
55
55
  keepEvents: 'session_truncate 默认保留的事件数(默认 20000)。',
56
56
  recentWriteWindowMs: '最近写入保护窗口;窗口内拒绝截断(默认 60000 毫秒)。',
57
- heartbeatMs: '活跃会话心跳间隔(默认 15000 毫秒;0 关闭自动写入)。',
57
+ heartbeatMs: '活跃会话心跳间隔(默认 15000 毫秒)。0 只关定时心跳与工具调用触发的写;turn 开始/结束仍按状态机维护 state/turn。',
58
58
  routerEnabled: '开启 Router 路由服务:判定工具调用并按策略记录/拦截(默认关闭)。',
59
59
  routerRuntime: '路由运行时模式:off / shadow / on(默认 off;要记录判定须显式设 shadow 或 on)。',
60
60
  routerProvider: 'Router 使用的 provider;留空则用 deepseek-official。',
@@ -107,6 +107,15 @@ const ZH = {
107
107
  pollMs: '总线轮询间隔(默认 3000 毫秒;0 关闭轮询)。',
108
108
  root: '总线根目录;留空则使用记忆根。',
109
109
  },
110
+ sharedBrain: {
111
+ $description: '共享大脑活动流:把各会话「正在干什么」写进活动板,每轮只注入未读的新增条目。',
112
+ activity: {
113
+ $description: '活动流开关与注入预算(与 presenceBus 无关,失败静默)。',
114
+ enabled: '开启共享大脑活动流(默认关闭)。',
115
+ injectMaxChars: '每次注入的总字符上限,含固定头尾(默认 300,范围 60–2000)。',
116
+ maxItems: '每次最多注入的活动条数(默认 5,范围 1–10)。',
117
+ },
118
+ },
110
119
  autoMaintenance: {
111
120
  $description: '每日维护:本地午夜调度 consolidate 报告与 forget 候选清单。',
112
121
  enabled: '开启每日维护(默认关闭)。',
@@ -162,7 +171,7 @@ const EN = {
162
171
  recoverOnStart: 'Finish or roll back an interrupted visible-truncation transaction at startup (on by default).',
163
172
  keepEvents: 'Default events kept by session_truncate (20000).',
164
173
  recentWriteWindowMs: 'Recent-write guard window; truncation is refused inside it (60000 ms).',
165
- heartbeatMs: 'Live-session heartbeat interval (15000 ms; 0 disables automatic writes).',
174
+ heartbeatMs: 'Live-session heartbeat interval (15000 ms). 0 disables only the timer heartbeat and tool-touch writes; turn start/end still maintain state/turn.',
166
175
  routerEnabled: 'Enable the Router service: evaluate tool calls and record/deny them by policy (off by default).',
167
176
  routerRuntime: 'Router runtime mode: off / shadow / on (default off; set shadow or on to record evaluations).',
168
177
  routerProvider: 'Provider used by Router; empty means deepseek-official.',
@@ -215,6 +224,15 @@ const EN = {
215
224
  pollMs: 'Bus polling interval (default 3000 ms; 0 disables polling).',
216
225
  root: 'Bus root directory; empty means the memory root.',
217
226
  },
227
+ sharedBrain: {
228
+ $description: 'Shared-brain activity stream: one board row per turn, and only unread rows are injected.',
229
+ activity: {
230
+ $description: 'Activity-stream switches and injection budget (independent of presenceBus; failures are silent).',
231
+ enabled: 'Enable the shared-brain activity stream (off by default).',
232
+ injectMaxChars: 'Total characters per injection, header/footer included (default 300, range 60–2000).',
233
+ maxItems: 'Maximum activity rows per injection (default 5, range 1–10).',
234
+ },
235
+ },
218
236
  autoMaintenance: {
219
237
  $description: 'Daily maintenance: schedule consolidate reports and forget candidates at local midnight.',
220
238
  enabled: 'Enable daily maintenance (off by default).',
@@ -292,6 +310,14 @@ function schemaOf() {
292
310
  root: z.string().description(ZH.presenceBus.root),
293
311
  }).collapse();
294
312
 
313
+ const sharedBrain = z.object({
314
+ activity: z.object({
315
+ enabled: z.boolean().default(false).description(ZH.sharedBrain.activity.enabled),
316
+ injectMaxChars: z.number().default(300).description(ZH.sharedBrain.activity.injectMaxChars),
317
+ maxItems: z.number().default(5).description(ZH.sharedBrain.activity.maxItems),
318
+ }).collapse(),
319
+ }).collapse();
320
+
295
321
  const autoMaintenance = z.object({
296
322
  enabled: z.boolean().default(false).description(ZH.autoMaintenance.enabled),
297
323
  consolidate: z.boolean().default(true).description(ZH.autoMaintenance.consolidate),
@@ -377,6 +403,7 @@ function schemaOf() {
377
403
  autoRecall,
378
404
  identityCard,
379
405
  presenceBus,
406
+ sharedBrain,
380
407
  autoMaintenance,
381
408
  routerFace,
382
409
  autoTruncate,