@jjh32456/dsh-longlive 1.6.6 → 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 (43) hide show
  1. package/CHANGELOG.md +40 -4
  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/compaction/engine-core.js +144 -97
  21. package/src/config.js +29 -2
  22. package/src/features.js +26 -0
  23. package/src/heartbeat.js +156 -23
  24. package/src/identity/handover.js +52 -1
  25. package/src/live.js +22 -2
  26. package/src/recall/privacy.mjs +105 -16
  27. package/src/router/service/board.mjs +95 -15
  28. package/src/router/service/hint.mjs +14 -0
  29. package/src/router/service/service.mjs +27 -11
  30. package/test/activity-realtime.test.mjs +959 -0
  31. package/test/activity-stream.test.mjs +512 -0
  32. package/test/autobind.test.mjs +25 -11
  33. package/test/config-schema.test.mjs +6 -0
  34. package/test/feature-flags.test.mjs +17 -0
  35. package/test/fixtures/activity-append-child.mjs +29 -0
  36. package/test/hot-truncate.test.mjs +113 -0
  37. package/test/live.test.mjs +6 -3
  38. package/test/packaging.test.mjs +7 -5
  39. package/test/router-background/router.test.mjs +44 -12
  40. package/test/run-rc.mjs +16 -0
  41. package/test/safety-defaults.test.mjs +5 -3
  42. package/test/state-truth.test.mjs +937 -0
  43. package/test/version-consistency.test.mjs +286 -0
@@ -342,21 +342,31 @@ function roughPointerTokens(text) {
342
342
  }
343
343
 
344
344
  /**
345
- * Select the oldest whole completed turns that sit outside the retention
346
- * window. Returns null when no safe cut exists (the common, expected answer).
345
+ * Select the oldest whole closed turns that sit outside the retention window.
346
+ * Returns null when no safe cut exists; every such refusal reports a structured
347
+ * reason through `helpers.onNoPlan` so a silently disabled hot truncate is
348
+ * observable (the engine logs it once per reason).
347
349
  *
348
- * The host protects surface position 0 when it holds the system prompt: a
349
- * user/message replacement may not shadow that node. This engine's contract is
350
- * stronger than the host's: a `system/message` is never part of a cut. The span
351
- * therefore starts at the first user/assistant node after every leading
352
- * system node, and a system node anywhere inside a candidate span is a hard
353
- * right boundary. Checkpoint nodes at the head of the span are folded into the
354
- * same cut, which keeps exactly one pointer.
350
+ * The leading run of `system/message` / `developer/message` nodes (the real
351
+ * system prompt at host-protected position 0) is never moved. A `system/message`
352
+ * re-injected by a host restart further inside the surface is an archived copy
353
+ * of that same prompt (host-tolerated inside a replacement range). Checkpoint
354
+ * nodes at the head of the span are folded into the same cut, which keeps
355
+ * exactly one pointer.
355
356
  */
356
357
  export function selectCutPlan(session, options, measurement, helpers = {}) {
357
358
  const table = helpers.table ?? turnTable(session);
358
359
  const groups = surfaceGroups(session, table, helpers.isCheckpointSource);
359
- if (groups.length === 0) return null;
360
+ const onNoPlan = typeof helpers.onNoPlan === 'function' ? helpers.onNoPlan : null;
361
+ const noPlan = (reason, detail) => {
362
+ try {
363
+ onNoPlan?.({ reason, detail: detail === undefined ? null : detail });
364
+ } catch {
365
+ // Diagnostics never break a plan.
366
+ }
367
+ return null;
368
+ };
369
+ if (groups.length === 0) return noPlan('no-groups');
360
370
  const tokens = new Map();
361
371
  for (const node of measurement?.nodes ?? []) {
362
372
  if (node && typeof node.seq === 'number' && Number.isFinite(node.tokens)) tokens.set(node.seq, node.tokens);
@@ -386,29 +396,37 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
386
396
  break;
387
397
  }
388
398
  }
389
- if (boundary <= 0) return null;
399
+ if (boundary <= 0) return noPlan('not-enough-turns', { groups: groups.length });
390
400
 
391
401
  const surfaceNodes = session?.surface?.nodes ?? [];
392
- if (surfaceNodes.length === 0) return null;
402
+ if (surfaceNodes.length === 0) return noPlan('no-surface');
393
403
 
394
- // 2026-11-06 1.6.6 hotfix — the aborted-turn freeze, second attempt.
404
+ // 2026-11-06 1.6.7 hardening — no permanent barrier, no silent zero progress.
395
405
  //
396
- // 1.6.5 stopped an aborted/unfinished turn from returning null, but it kept
397
- // the old "never cut an aborted turn" rule: the span was moved *after* the
398
- // last aborted turn in the prefix. When aborted turns recur near the tail
399
- // (restart-aborted / interrupted turns; real session 2026-11-06: turns 48,
400
- // 74, 85, 90, 94), the last one sits right before the retention boundary, so
401
- // the plan degenerates to "cut the single completed turn after it" (6–18k
402
- // tokens) while every older completed turn (≈370k tokens) stays pinned in
403
- // the surface as a permanent prefix. That is the exact shape of the
404
- // 396k-token context.
406
+ // 1.6.6 removed the aborted-turn freeze but treated every group without a
407
+ // usable turn record as an immovable open group and stopped the span before
408
+ // it. Three surface shapes then made the planner return null forever even
409
+ // though whole closed turns older than the retention window existed:
410
+ // * a log truncated mid-turn leaves orphan messages at the surface head
411
+ // (no turn/start) — a turn===0 group at the head (red team E1);
412
+ // * the host appends a user/message between turns — a turn===0 group in
413
+ // the middle (red team E3);
414
+ // * a turn whose turn/end was lost by log truncation — turn N has no
415
+ // endSeq but later turns exist (red team E2).
405
416
  //
406
- // A turn with a `turn/end` event is closed: it never gains new events, its
407
- // tool pairs are complete by construction, and cutting it whole keeps the
408
- // pairing balanced. Only a turn with no `turn/end` yet (the currently open
409
- // turn, or a turn the log was truncated inside) must stay in place. So the
410
- // span may now cover closed-but-not-completed turns; `cutTurnGroups` below
411
- // includes them and the archive copies the full turn range via byNumber.
417
+ // Rules now:
418
+ // * the whole LEADING run of system/message + developer/message nodes is
419
+ // never moved (head instructions; host-protected position 0);
420
+ // * a turn===0 group that carries a system/developer node is a head
421
+ // instruction zone and stays in place; a turn===0 group of plain messages
422
+ // is an orphan prefix and is archived with the cut (recoverable);
423
+ // * a group whose turn has no turn/end is the CURRENT turn only when it is
424
+ // the last started turn; an earlier turn without turn/end is abandoned
425
+ // (the log was truncated inside it) and is cuttable, archived through an
426
+ // inferred range up to the next turn/start;
427
+ // * the planner skips a blocked head barrier and cuts the next run of
428
+ // wholly-cuttable groups; if nothing is cuttable it reports the reason
429
+ // through onNoPlan instead of returning null silently.
412
430
  const groupStart = new Array(groups.length);
413
431
  const groupEnd = new Array(groups.length);
414
432
  const groupOfPosition = new Array(surfaceNodes.length);
@@ -420,99 +438,118 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
420
438
  groupEnd[index] = cursor - 1;
421
439
  }
422
440
  }
441
+ const typeOfSeq = (seq) => table.bySeq.get(seq)?.type;
442
+ const isInstructionSeq = (seq) => typeOfSeq(seq) === 'system/message' || typeOfSeq(seq) === 'developer/message';
443
+ const protectedSeqs = new Set();
444
+ {
445
+ let position = 0;
446
+ while (position < surfaceNodes.length && isInstructionSeq(surfaceNodes[position])) {
447
+ protectedSeqs.add(surfaceNodes[position]);
448
+ position += 1;
449
+ }
450
+ }
451
+ const lastStarted = table.turns.length > 0 ? table.turns[table.turns.length - 1] : null;
423
452
  const turnInfoOf = (group) => (group.kind === 'turn' && group.turn !== 0 ? table.byNumber.get(group.turn) : undefined);
424
- const isOpenGroup = (group) => {
453
+ const isCurrentTurnGroup = (group) => {
454
+ if (group.kind !== 'turn' || group.turn === 0) return false;
455
+ const info = table.byNumber.get(group.turn);
456
+ if (info === undefined) return true;
457
+ return info.endSeq === null && lastStarted !== null && info.turn === lastStarted.turn;
458
+ };
459
+ const isCuttableGroup = (group) => {
460
+ if (group === undefined) return false;
461
+ if (group.kind === 'checkpoint') return true;
425
462
  if (group.kind !== 'turn') return false;
426
- if (group.turn === 0) return true;
427
- const info = turnInfoOf(group);
428
- return info === undefined || info.endSeq === null;
463
+ // A turn===0 group is either an orphan head zone (contains instructions) or
464
+ // a stray message block between turns; only the latter may be archived.
465
+ if (group.turn === 0) return !group.seqs.some((seq) => isInstructionSeq(seq));
466
+ const info = table.byNumber.get(group.turn);
467
+ if (info === undefined) return false;
468
+ return !isCurrentTurnGroup(group);
469
+ };
470
+ // An abandoned turn (no turn/end, but a later turn/start exists) never got a
471
+ // closing event; the archive range is inferred up to the next turn/start so
472
+ // every one of its original events stays recoverable.
473
+ const resolvedEndSeq = (group) => {
474
+ const info = table.byNumber.get(group.turn);
475
+ const lastSurfaceSeq = group.seqs[group.seqs.length - 1];
476
+ if (info === undefined || !Number.isInteger(info.startSeq)) return lastSurfaceSeq;
477
+ if (Number.isInteger(info.endSeq)) return info.endSeq;
478
+ const next = table.turns.find((entry) => Number.isInteger(entry.startSeq) && entry.startSeq > info.startSeq);
479
+ if (next === undefined) return lastSurfaceSeq;
480
+ return Math.max(info.startSeq, next.startSeq - 1);
429
481
  };
430
482
 
431
- // The head of the surface is the real system prompt: the host protects
432
- // position 0 and this engine keeps the whole leading run of system/message
433
- // nodes in place. The span starts at the first user/assistant node after it.
483
+ // The span starts after the protected head run and on a user/assistant node
484
+ // (the pointer replaces one); a system copy skipped at the head of a turn
485
+ // stays outside the span exactly like 1.6.6 — it is never moved out.
434
486
  let startIndex = 0;
435
- while (startIndex < surfaceNodes.length
436
- && table.bySeq.get(surfaceNodes[startIndex])?.type === 'system/message') {
437
- startIndex += 1;
438
- }
439
- // The pointer replaces a user/assistant message, so the first cut node must
440
- // be one. A leading developer node is kept in place instead of cut.
487
+ while (startIndex < surfaceNodes.length && protectedSeqs.has(surfaceNodes[startIndex])) startIndex += 1;
441
488
  while (startIndex < surfaceNodes.length) {
442
- const type = table.bySeq.get(surfaceNodes[startIndex])?.type;
489
+ const type = typeOfSeq(surfaceNodes[startIndex]);
443
490
  if (type === 'user/message' || type === 'assistant/message') break;
444
491
  startIndex += 1;
445
492
  }
446
- if (startIndex >= surfaceNodes.length) return null;
447
- // A leading group may start with the injected system prompt and continue with
448
- // the turn's own nodes. The system nodes stay outside; the span starts at the
449
- // first non-system node of that group — the skipped prefix is system-only, so
450
- // no user/assistant/tool node is left half-cut. A group whose head contains
451
- // non-system nodes is kept whole instead.
452
- let startGroup = groupOfPosition[startIndex];
453
- while (startGroup < groups.length && groupStart[startGroup] < startIndex) {
454
- let systemOnlyHead = true;
455
- for (let position = groupStart[startGroup]; position < startIndex; position += 1) {
456
- if (table.bySeq.get(surfaceNodes[position])?.type !== 'system/message') { systemOnlyHead = false; break; }
457
- }
458
- if (systemOnlyHead) break;
459
- startIndex = groupEnd[startGroup] + 1;
460
- while (startIndex < surfaceNodes.length) {
461
- const type = table.bySeq.get(surfaceNodes[startIndex])?.type;
462
- if (type === 'user/message' || type === 'assistant/message') break;
463
- startIndex += 1;
493
+ if (startIndex >= surfaceNodes.length) return noPlan('no-span-start');
494
+ const firstGroup = groupOfPosition[startIndex];
495
+
496
+ // Earliest run of consecutive cuttable groups that lies entirely before the
497
+ // retention boundary and holds at least one real closed turn. Runs made only
498
+ // of orphan messages/pointers are not worth a surface rewrite: skip them.
499
+ let chosenStart = -1;
500
+ let chosenEnd = -1;
501
+ for (let index = firstGroup; index < boundary; index += 1) {
502
+ if (!isCuttableGroup(groups[index])) continue;
503
+ let runEnd = index;
504
+ while (runEnd + 1 < boundary && isCuttableGroup(groups[runEnd + 1])) runEnd += 1;
505
+ let realTurns = 0;
506
+ for (let cursor = index; cursor <= runEnd; cursor += 1) {
507
+ if (groups[cursor].kind === 'turn' && groups[cursor].turn !== 0) realTurns += 1;
464
508
  }
465
- if (startIndex >= surfaceNodes.length) return null;
466
- startGroup = groupOfPosition[startIndex];
509
+ if (realTurns > 0) { chosenStart = index; chosenEnd = runEnd; break; }
510
+ index = runEnd;
467
511
  }
468
-
469
- // Cut everything older than the retention boundary, minus groups that must
470
- // stay in place: the currently open/unclosed turn and groups without a turn
471
- // record are never part of the span. Every other group is cut whole. This is
472
- // what lets a closed aborted turn be removed together with its neighbours
473
- // instead of acting as a barrier the span has to jump over.
474
- let endGroup = boundary - 1;
475
- let blockedAt = -1;
476
- for (let index = startGroup; index <= endGroup; index += 1) {
477
- if (isOpenGroup(groups[index])) { blockedAt = index; break; }
512
+ if (chosenStart < 0) {
513
+ return noPlan('blocked-prefix', {
514
+ firstGroup,
515
+ boundary,
516
+ blocked: groups.slice(firstGroup, boundary).map((group) => `${group.kind}:${group.turn}:${group.completed}`),
517
+ });
478
518
  }
479
- if (blockedAt >= 0) endGroup = blockedAt - 1;
480
- if (endGroup < startGroup) return null;
481
- const endIndex = groupEnd[endGroup];
519
+ let spanStart = Math.max(startIndex, groupStart[chosenStart]);
520
+ const endIndex = groupEnd[chosenEnd];
521
+ if (endIndex < spanStart) return noPlan('empty-span', { chosenStart, chosenEnd });
482
522
 
483
523
  // A system/message node *inside* the span is a host-restart re-injection of
484
- // the same prompt (the protected copy is position 0, handled above). The
485
- // previous revision excluded the whole containing turn; in the real session
486
- // that pinned turns 66/72/75 (≈100k tokens of ordinary dialogue carrying one
487
- // injected copy each) outside every future cut, so the context could never
488
- // reach the retention window. The host tolerates later system nodes inside a
489
- // replacement range, so an injected copy is archived together with the old
490
- // prefix it belongs to. Nodes before the span start are never touched.
491
- const shadowedSeqs = surfaceNodes.slice(startIndex, endIndex + 1);
524
+ // the same prompt; the host tolerates it inside a replacement range and it is
525
+ // archived together with the old prefix (approved 1.6.6 behaviour).
526
+ const shadowedSeqs = surfaceNodes.slice(spanStart, endIndex + 1);
492
527
  const shadowedSet = new Set(shadowedSeqs);
493
528
  const touchedGroups = groups.filter((group) => group.seqs.some((seq) => shadowedSet.has(seq)));
494
- if (touchedGroups.some((group) => isOpenGroup(group))) return null;
529
+ if (touchedGroups.some((group) => isCurrentTurnGroup(group))) return noPlan('current-turn-touched');
530
+ // No half turn: every touched turn group must be fully shadowed. The only
531
+ // allowed exception is a system/developer copy skipped at the head of the
532
+ // first group (it stays on the surface, never half-cut).
495
533
  for (const group of touchedGroups) {
496
534
  if (group.kind !== 'turn') continue;
497
535
  const missing = group.seqs.filter((seq) => !shadowedSet.has(seq));
498
- if (missing.some((seq) => table.bySeq.get(seq)?.type !== 'system/message')) return null;
536
+ if (missing.some((seq) => !isInstructionSeq(seq))) return noPlan('split-group', { turn: group.turn });
499
537
  }
500
538
  const cutTurnGroups = touchedGroups.filter((group) => {
501
539
  if (group.kind !== 'turn' || group.turn === 0) return false;
502
- const info = turnInfoOf(group);
503
- return info !== undefined && info.endSeq !== null;
540
+ return !isCurrentTurnGroup(group);
504
541
  });
505
- if (cutTurnGroups.length === 0) return null;
542
+ if (cutTurnGroups.length === 0) return noPlan('no-turns', { chosenStart, chosenEnd });
506
543
 
507
- // Archive every original event of the cut turns, plus any checkpoint node
508
- // that positionally lived inside the replaced span (a previous pointer).
544
+ // Archive every original event of the cut turns, plus any checkpoint node and
545
+ // orphan (turn===0) message that positionally lived inside the replaced span.
509
546
  const archivedSeqs = new Set();
510
547
  const archiveEvents = [];
511
548
  for (const group of cutTurnGroups) {
512
- const startSeq = table.byNumber.get(group.turn)?.startSeq;
513
- const endSeq = table.byNumber.get(group.turn)?.endSeq;
514
- if (!Number.isInteger(startSeq) || !Number.isInteger(endSeq)) continue;
515
- for (let seq = startSeq; seq <= endSeq; seq += 1) {
549
+ const info = table.byNumber.get(group.turn);
550
+ if (info === undefined || !Number.isInteger(info.startSeq)) continue;
551
+ const endSeq = resolvedEndSeq(group);
552
+ for (let seq = info.startSeq; seq <= endSeq; seq += 1) {
516
553
  const event = table.bySeq.get(seq);
517
554
  if (event === undefined || archivedSeqs.has(seq)) continue;
518
555
  archivedSeqs.add(seq);
@@ -536,13 +573,13 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
536
573
  firstSeq: seqs[0],
537
574
  lastSeq: seqs[seqs.length - 1],
538
575
  startSeq: info?.startSeq ?? seqs[0],
539
- endSeq: info?.endSeq ?? seqs[seqs.length - 1],
576
+ endSeq: resolvedEndSeq(group),
540
577
  surfaceEvents: seqs.length,
541
578
  tokens: seqs.reduce((total, seq) => total + prices.get(seq), 0),
542
579
  };
543
580
  });
544
581
  const shadowedTokenCount = shadowedSeqs.reduce((total, seq) => total + prices.get(seq), 0);
545
- if (shadowedTokenCount <= 0) return null;
582
+ if (shadowedTokenCount <= 0) return noPlan('zero-token');
546
583
 
547
584
  const lastTouchedIndex = groups.indexOf(touchedGroups[touchedGroups.length - 1]);
548
585
  const retainedGroups = groups.slice(lastTouchedIndex + 1);
@@ -569,7 +606,9 @@ export function selectCutPlan(session, options, measurement, helpers = {}) {
569
606
  retainedTurns,
570
607
  });
571
608
  const pointerTokens = roughPointerTokens(previewText);
572
- if (shadowedTokenCount < pointerTokens + options.minCutTokens) return null;
609
+ if (shadowedTokenCount < pointerTokens + options.minCutTokens) {
610
+ return noPlan('pointer-too-large', { shadowedTokenCount, pointerTokens });
611
+ }
573
612
 
574
613
  return {
575
614
  start: shadowedSeqs[0],
@@ -895,6 +934,14 @@ export function createCutEngine(deps) {
895
934
  return selectCutPlan(session, this.options, measurement, {
896
935
  table,
897
936
  isCheckpointSource: isCompactCheckpointSource,
937
+ // "cut, or say why not": a refusal that would otherwise be silent is
938
+ // logged once per reason (warnOnce) so a permanently disabled hot
939
+ // truncate is always observable in the host log.
940
+ onNoPlan: (info) => {
941
+ const reason = String(info?.reason ?? 'unknown');
942
+ const detail = info?.detail === undefined || info?.detail === null ? '' : ` ${JSON.stringify(info.detail)}`;
943
+ this.warnOnce(`no-plan:${reason}`, `hot-truncate: 未切(${reason})${detail}`);
944
+ },
898
945
  });
899
946
  }
900
947
 
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,
package/src/features.js CHANGED
@@ -9,6 +9,8 @@
9
9
  // identityCard.enabled -> agent/pre-step (open card, prepend:true)
10
10
  // autoRecall.enabled -> agent/pre-step (recall, prepend:false) + session/event
11
11
  // presenceBus.enabled -> session/event (drain this session's bus inbox)
12
+ // sharedBrain.activity.enabled -> session/event (write this turn's activity)
13
+ // + agent/pre-step (inject other sessions' new rows)
12
14
  // autoMaintenance.enabled -> one unref'd timer to the next local midnight
13
15
  // (consolidate report + forget dry-run pending list;
14
16
  // no turn hook, so a failure cannot affect a turn)
@@ -40,6 +42,7 @@ import {
40
42
  import { apply as applyAutoRecall } from './recall/index.js';
41
43
  import { createBus, normalizeSessionId, shouldAckOnTurnEnd } from './bus/bus.mjs';
42
44
  import { createMaintenanceScheduler } from './memory/maintenance.js';
45
+ import { normalizeActivityConfig, registerActivityStream } from './activity/index.js';
43
46
  import {
44
47
  claimFallbackInjection,
45
48
  markReceiptFallbackInjected,
@@ -52,6 +55,9 @@ export const FEATURE_DEFAULTS = Object.freeze({
52
55
  autoRecall: Object.freeze({ enabled: false, topK: 3, budgetTokens: 2000, everyNTurns: 6 }),
53
56
  identityCard: Object.freeze({ enabled: false, path: '<memoryRoot>/STATE.md', maxChars: 800 }),
54
57
  presenceBus: Object.freeze({ enabled: false, pollMs: 3000 }),
58
+ sharedBrain: Object.freeze({
59
+ activity: Object.freeze({ enabled: false, injectMaxChars: 300, maxItems: 5 }),
60
+ }),
55
61
  autoMaintenance: Object.freeze({
56
62
  enabled: false,
57
63
  consolidate: true,
@@ -81,6 +87,8 @@ export function normalizeFeatureConfig(config = {}) {
81
87
  const auto = value.autoRecall && typeof value.autoRecall === 'object' ? value.autoRecall : {};
82
88
  const identity = value.identityCard && typeof value.identityCard === 'object' ? value.identityCard : {};
83
89
  const bus = value.presenceBus && typeof value.presenceBus === 'object' ? value.presenceBus : {};
90
+ const sharedBrain = value.sharedBrain && typeof value.sharedBrain === 'object' ? value.sharedBrain : {};
91
+ const activity = sharedBrain.activity && typeof sharedBrain.activity === 'object' ? sharedBrain.activity : {};
84
92
  const maintenance = value.autoMaintenance && typeof value.autoMaintenance === 'object'
85
93
  ? value.autoMaintenance
86
94
  : {};
@@ -105,6 +113,9 @@ export function normalizeFeatureConfig(config = {}) {
105
113
  pollMs: clampInt(bus.pollMs, FEATURE_DEFAULTS.presenceBus.pollMs, 0, 3600000),
106
114
  root: text(bus.root),
107
115
  },
116
+ sharedBrain: {
117
+ activity: normalizeActivityConfig(activity),
118
+ },
108
119
  autoMaintenance: {
109
120
  enabled: bool(maintenance.enabled),
110
121
  consolidate: maintenance.consolidate !== false,
@@ -566,6 +577,21 @@ export function registerFeatures(ctx, config = {}, options = {}) {
566
577
  enabled.push('presenceBus');
567
578
  }
568
579
 
580
+ if (cfg.sharedBrain.activity.enabled) {
581
+ if (!memoryRoot) {
582
+ warn('[longlive] sharedBrain.activity 已开启但没有可解析的 memoryRoot — 已跳过');
583
+ } else {
584
+ const stream = registerActivityStream(ctx, cfg.sharedBrain.activity, {
585
+ memoryRoot,
586
+ // Test seam only; the host config never carries these.
587
+ now: config.now,
588
+ privacyScan: typeof config.activityPrivacyScan === 'function' ? config.activityPrivacyScan : undefined,
589
+ logger: warn,
590
+ });
591
+ if (stream.enabled) enabled.push('sharedBrain.activity');
592
+ }
593
+ }
594
+
569
595
  if (cfg.autoMaintenance.enabled) {
570
596
  if (!memoryRoot) {
571
597
  warn('[longlive] autoMaintenance 已开启但没有可解析的 memoryRoot — 已跳过');