@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,649 @@
1
+ // activity/index.js — shared-brain activity stream wiring (1.6.1, event-level).
2
+ //
3
+ // Producer: `session/event` writes into <memoryRoot>/.longlive/board/activity.jsonl.
4
+ // - `tool/call` (and `tool/result` with isError) refresh this turn's 「最近动作」
5
+ // row immediately, as 「工具:目标」 (e.g. 「pwsh:libtv 锚图 S08-S10」), so a
6
+ // sibling session sees a >10-minute turn while it is still running. Same-key
7
+ // refreshes are throttled to ≤1 write/10s; a merge bumps the row's `seq`
8
+ // (touchSeq) so the injection cursor below actually sees the refresh.
9
+ // - `turn/end` writes the local-rule conclusion row (user gist · assistant
10
+ // conclusion · latest tool action, ≤140 chars) as before.
11
+ // No model call, no session-log scan, no tool invocation.
12
+ //
13
+ // Consumer: `agent/pre-step` (step 1) → inject only rows with seq greater than
14
+ // this session's lastSeenSeq, other sessions only, at most `maxItems` rows and
15
+ // at most `injectMaxChars` characters (header/footer included). No rows means
16
+ // no write — zero token cost. Because every event-level refresh bumps seq, the
17
+ // cursor never gets stuck behind a long turn's first row.
18
+ //
19
+ // Failure policy: every path is wrapped; a broken board, a missing lock, a
20
+ // privacy verdict or a host without @deepseek-ai/dsh-llm just skips this
21
+ // feature. A turn is never blocked by the activity stream.
22
+
23
+ import path from 'node:path';
24
+
25
+ import { isUserAuthoredMessage } from '../truncate-visible.js';
26
+ import {
27
+ assertInjectionSource,
28
+ injectionSource,
29
+ insertionIndex,
30
+ isHumanMessage,
31
+ textOfMessage,
32
+ } from '../identity/inject-position.js';
33
+ import { privacyScan as defaultPrivacyScan } from '../recall/privacy.mjs';
34
+ import {
35
+ ACTIVITY_HINT_MARK,
36
+ ACTIVITY_REFRESH_THROTTLE_MS,
37
+ buildActivityKey,
38
+ buildActivityText,
39
+ buildToolActivityText,
40
+ classifyKind,
41
+ clip,
42
+ oneLine,
43
+ renderActivityHint,
44
+ selectActivities,
45
+ shortSessionId,
46
+ } from './model.js';
47
+ import {
48
+ ACTIVITY_SESSION_RE,
49
+ activityBoardFile,
50
+ createActivityStore,
51
+ readJsonFile,
52
+ writeJsonAtomic,
53
+ } from './store.js';
54
+
55
+ export const ACTIVITY_STATE_VERSION = 1;
56
+ export const ACTIVITY_STATE_DIR = path.join('.longlive', 'bridge');
57
+
58
+ export const ACTIVITY_DEFAULTS = Object.freeze({
59
+ enabled: false,
60
+ injectMaxChars: 300,
61
+ maxItems: 5,
62
+ });
63
+
64
+ function clampInt(value, fallback, min, max) {
65
+ const number = Number(value);
66
+ if (!Number.isFinite(number)) return fallback;
67
+ return Math.min(max, Math.max(min, Math.floor(number)));
68
+ }
69
+
70
+ /** Pure normalizer; the config schema uses the same defaults. */
71
+ export function normalizeActivityConfig(input = {}) {
72
+ const raw = input !== null && typeof input === 'object' ? input : {};
73
+ return {
74
+ enabled: raw.enabled === true,
75
+ injectMaxChars: clampInt(raw.injectMaxChars, ACTIVITY_DEFAULTS.injectMaxChars, 60, 2000),
76
+ maxItems: clampInt(raw.maxItems, ACTIVITY_DEFAULTS.maxItems, 1, 10),
77
+ createUserMessage: typeof raw.createUserMessage === 'function' ? raw.createUserMessage : undefined,
78
+ };
79
+ }
80
+
81
+ export function activityStateFile(memoryRoot, sessionId) {
82
+ return path.join(String(memoryRoot ?? ''), ACTIVITY_STATE_DIR, `${sessionId}.activity.json`);
83
+ }
84
+
85
+ export function emptyActivityState(sessionId) {
86
+ return {
87
+ v: ACTIVITY_STATE_VERSION,
88
+ sessionId,
89
+ lastSeenSeq: 0,
90
+ lastInjectedSeq: 0,
91
+ lastInjectedAt: null,
92
+ injectedTurns: 0,
93
+ resets: 0,
94
+ lastResetAt: null,
95
+ updatedAt: null,
96
+ };
97
+ }
98
+
99
+ export function readActivityState(memoryRoot, sessionId) {
100
+ const read = readJsonFile(activityStateFile(memoryRoot, sessionId));
101
+ const value = read.ok ? read.value : null;
102
+ if (!value || value.v !== ACTIVITY_STATE_VERSION || value.sessionId !== sessionId) {
103
+ return emptyActivityState(sessionId);
104
+ }
105
+ return {
106
+ ...emptyActivityState(sessionId),
107
+ ...value,
108
+ lastSeenSeq: Number.isInteger(value.lastSeenSeq) && value.lastSeenSeq >= 0 ? value.lastSeenSeq : 0,
109
+ lastInjectedSeq: Number.isInteger(value.lastInjectedSeq) && value.lastInjectedSeq >= 0 ? value.lastInjectedSeq : 0,
110
+ injectedTurns: Number.isInteger(value.injectedTurns) && value.injectedTurns >= 0 ? value.injectedTurns : 0,
111
+ resets: Number.isInteger(value.resets) && value.resets >= 0 ? value.resets : 0,
112
+ };
113
+ }
114
+
115
+ function textBlocks(content) {
116
+ if (!Array.isArray(content)) return '';
117
+ const parts = [];
118
+ for (const block of content) {
119
+ if (!block || block.type !== 'text' || typeof block.text !== 'string') continue;
120
+ const text = block.text.trim();
121
+ if (text !== '') parts.push(text);
122
+ }
123
+ return parts.join('\n');
124
+ }
125
+
126
+ function messageTextOf(event) {
127
+ const data = event?.data ?? {};
128
+ const message = data.message && typeof data.message === 'object' ? data.message : data;
129
+ return textBlocks(message?.content);
130
+ }
131
+
132
+ function sessionIdOfMessage(session, event) {
133
+ const raw = session?.id
134
+ ?? session?.header?.id
135
+ ?? event?.sessionId
136
+ ?? '';
137
+ if (typeof raw !== 'string') return '';
138
+ const id = raw.trim().toLowerCase();
139
+ return ACTIVITY_SESSION_RE.test(id) ? id : '';
140
+ }
141
+
142
+ function toolSummaryOf(data) {
143
+ const name = oneLine(data?.name ?? 'tool');
144
+ const args = data?.arguments;
145
+ let snippet = '';
146
+ if (typeof args === 'string' && args.trim() !== '') {
147
+ let parsed = null;
148
+ try {
149
+ parsed = JSON.parse(args);
150
+ } catch {
151
+ parsed = null;
152
+ }
153
+ if (parsed && typeof parsed === 'object') {
154
+ for (const key of ['command', 'cmd', 'path', 'file_path', 'query', 'pattern', 'glob', 'description', 'prompt', 'url']) {
155
+ const value = parsed[key];
156
+ if (typeof value === 'string' && value.trim() !== '') {
157
+ snippet = value;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ if (snippet === '') snippet = args;
163
+ }
164
+ return { name, summary: clip(oneLine(snippet), 44) };
165
+ }
166
+
167
+ function headerOf(payload) {
168
+ const agent = payload?.agent;
169
+ return agent?.session?.header ?? agent?.header ?? payload?.session?.header ?? {};
170
+ }
171
+
172
+ function sessionOf(payload) {
173
+ const agent = payload?.agent;
174
+ return agent?.session ?? payload?.session ?? null;
175
+ }
176
+
177
+ function preStepSessionId(payload) {
178
+ const header = headerOf(payload);
179
+ const agent = payload?.agent;
180
+ const raw = header.id ?? agent?.sessionId ?? sessionOf(payload)?.id ?? payload?.sessionId;
181
+ if (typeof raw !== 'string') return '';
182
+ const id = raw.trim().toLowerCase();
183
+ return ACTIVITY_SESSION_RE.test(id) ? id : '';
184
+ }
185
+
186
+ function normalizeOrigin(value) {
187
+ return typeof value === 'string' ? value.trim().toLowerCase() : '';
188
+ }
189
+
190
+ function maxSeqOf(entries) {
191
+ let max = 0;
192
+ for (const entry of Array.isArray(entries) ? entries : []) {
193
+ if (Number.isInteger(entry?.seq) && entry.seq > max) max = entry.seq;
194
+ }
195
+ return max;
196
+ }
197
+
198
+ async function resolveCreateUserMessage(explicit) {
199
+ if (typeof explicit === 'function') return explicit;
200
+ try {
201
+ const mod = await import('@deepseek-ai/dsh-llm');
202
+ return typeof mod?.createUserMessage === 'function' ? mod.createUserMessage : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Create the stream object. `enabled` + `memoryRoot` are the only switches;
210
+ * everything else is a budget. Tests drive `onSessionEvent` / `maybeInject`
211
+ * directly, which is the same path the hooks use.
212
+ */
213
+ export function createActivityStream(options = {}) {
214
+ const cfg = normalizeActivityConfig(options);
215
+ const memoryRoot = typeof options.memoryRoot === 'string' && options.memoryRoot.trim() !== ''
216
+ ? path.resolve(options.memoryRoot)
217
+ : null;
218
+ const now = typeof options.now === 'function' ? options.now : () => new Date();
219
+ const privacyScan = typeof options.privacyScan === 'function' ? options.privacyScan : defaultPrivacyScan;
220
+ const logger = typeof options.logger === 'function' ? options.logger : () => {};
221
+ const stats = {
222
+ turns: 0,
223
+ events: 0,
224
+ written: 0,
225
+ refreshed: 0,
226
+ merged: 0,
227
+ distilled: 0,
228
+ throttled: 0,
229
+ injected: 0,
230
+ skipped: 0,
231
+ privacy: 0,
232
+ resets: 0,
233
+ errors: 0,
234
+ };
235
+ const sessions = new Map();
236
+ // `${sessionId}|${key}` → { at: ms, kind } — 同 key 节流 ≤1 次刷新/10s.
237
+ const refreshes = new Map();
238
+ const disposers = [];
239
+ const store = memoryRoot ? createActivityStore({ memoryRoot, now, privacyScan }) : null;
240
+ const enabled = cfg.enabled === true && store !== null;
241
+
242
+ function stateFor(sessionId) {
243
+ let state = sessions.get(sessionId);
244
+ if (!state) {
245
+ state = {
246
+ sessionId,
247
+ turn: null,
248
+ userText: '',
249
+ assistantText: '',
250
+ tool: null,
251
+ toolCalls: 0,
252
+ };
253
+ sessions.set(sessionId, state);
254
+ }
255
+ return state;
256
+ }
257
+
258
+ function clearSessionRefreshes(sessionId) {
259
+ const prefix = `${sessionId}|`;
260
+ for (const key of refreshes.keys()) {
261
+ if (key.startsWith(prefix)) refreshes.delete(key);
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Event-level write: one refresh per tool call, as 「工具:目标」.
267
+ *
268
+ * Same-key refreshes are throttled to one per ACTIVITY_REFRESH_THROTTLE_MS —
269
+ * a 12-call burst must not append 12 near-identical rows. A tool error may
270
+ * skip the window once (kind escalation to blocked), after that it throttles
271
+ * like everything else. The store merge bumps `seq`, which is what lets the
272
+ * consumer's seq-only cursor pick the update up.
273
+ */
274
+ function writeToolActivity(state, { isError = false } = {}) {
275
+ const tool = state.tool;
276
+ if (!tool || typeof tool.name !== 'string' || tool.name.trim() === '') {
277
+ return { written: false, reason: 'no-tool' };
278
+ }
279
+ const failed = isError === true || tool.isError === true;
280
+ const text = buildToolActivityText(tool, { isError: failed });
281
+ if (text === '') return { written: false, reason: 'empty' };
282
+ const key = buildActivityKey({
283
+ userText: state.userText,
284
+ assistantText: state.assistantText,
285
+ tool,
286
+ });
287
+ const nowDate = now();
288
+ const nowMs = nowDate.getTime();
289
+ const refreshKey = `${state.sessionId}|${key}`;
290
+ const last = refreshes.get(refreshKey) ?? null;
291
+ const within = last !== null && Number.isFinite(nowMs) && nowMs - last.at < ACTIVITY_REFRESH_THROTTLE_MS;
292
+ const escalating = failed && (last === null || last.kind !== 'blocked');
293
+ if (within && !escalating) {
294
+ stats.throttled += 1;
295
+ return { written: false, reason: 'throttled' };
296
+ }
297
+ const kind = failed ? 'blocked' : classifyKind({ userText: state.userText, tool });
298
+ const result = store.append(
299
+ { sessionId: state.sessionId, kind, text, key, at: nowDate },
300
+ { touchSeq: true, refreshText: true },
301
+ );
302
+ if (!result.ok) {
303
+ if (result.code === 'E_PRIVACY') stats.privacy += 1;
304
+ else stats.errors += 1;
305
+ return { written: false, reason: result.code ?? 'error' };
306
+ }
307
+ refreshes.set(refreshKey, { at: nowMs, kind });
308
+ if (result.action === 'distilled') stats.distilled += 1;
309
+ else if (result.action === 'refreshed') stats.refreshed += 1;
310
+ else if (result.action === 'merged') stats.merged += 1;
311
+ else stats.written += 1;
312
+ return {
313
+ written: true,
314
+ action: result.action,
315
+ seq: result.seq,
316
+ seqBumped: result.seqBumped === true,
317
+ entry: result.entry,
318
+ };
319
+ }
320
+
321
+ function writeTurn(state, reason, turn) {
322
+ let text = buildActivityText({
323
+ userText: state.userText,
324
+ assistantText: state.assistantText,
325
+ tool: state.tool,
326
+ });
327
+ let kind = classifyKind({
328
+ userText: state.userText,
329
+ assistantText: state.assistantText,
330
+ tool: state.tool,
331
+ });
332
+ if (reason === 'failed') {
333
+ kind = 'blocked';
334
+ if (text === '') text = '回合失败(已记录为 blocked)';
335
+ }
336
+ if (text === '') {
337
+ stats.skipped += 1;
338
+ return { written: false, reason: 'empty' };
339
+ }
340
+ const candidate = {
341
+ sessionId: state.sessionId,
342
+ kind,
343
+ text,
344
+ key: buildActivityKey({
345
+ userText: state.userText,
346
+ assistantText: state.assistantText,
347
+ tool: state.tool,
348
+ }),
349
+ at: now(),
350
+ };
351
+ // A conclusion merge also carries new information for sibling sessions:
352
+ // bump seq and let the newest conclusion replace the turn's action text.
353
+ const result = store.append(candidate, { touchSeq: true, refreshText: true });
354
+ if (!result.ok) {
355
+ if (result.code === 'E_PRIVACY') stats.privacy += 1;
356
+ else stats.errors += 1;
357
+ return { written: false, reason: result.code ?? 'error', turn };
358
+ }
359
+ if (result.action === 'distilled') stats.distilled += 1;
360
+ else if (result.action === 'refreshed') stats.refreshed += 1;
361
+ else if (result.action === 'merged') stats.merged += 1;
362
+ else stats.written += 1;
363
+ return { written: true, action: result.action, seqBumped: result.seqBumped === true, entry: result.entry, turn };
364
+ }
365
+
366
+ function onSessionEvent(session, event) {
367
+ if (!enabled) return;
368
+ try {
369
+ const type = event?.type;
370
+ if (typeof type !== 'string') return;
371
+ const sessionId = sessionIdOfMessage(session, event);
372
+ if (type === 'session/disposed') {
373
+ if (sessionId !== '') {
374
+ sessions.delete(sessionId);
375
+ clearSessionRefreshes(sessionId);
376
+ }
377
+ return;
378
+ }
379
+ if (sessionId === '') return;
380
+ const state = stateFor(sessionId);
381
+
382
+ if (type === 'turn/start') {
383
+ const turn = event?.data?.turn;
384
+ state.turn = Number.isInteger(turn) ? turn : state.turn;
385
+ state.userText = '';
386
+ state.assistantText = '';
387
+ state.tool = null;
388
+ state.toolCalls = 0;
389
+ // A new turn is new information: do not let the previous turn's 10s
390
+ // window swallow its first tool action.
391
+ clearSessionRefreshes(sessionId);
392
+ return;
393
+ }
394
+
395
+ if (type === 'user/message') {
396
+ if (!isUserAuthoredMessage(event)) return;
397
+ const text = messageTextOf(event);
398
+ if (text !== '') state.userText = text;
399
+ return;
400
+ }
401
+
402
+ if (type === 'assistant/message') {
403
+ const data = event?.data ?? {};
404
+ const turn = data.turn;
405
+ if (Number.isInteger(turn) && Number.isInteger(state.turn) && turn !== state.turn) return;
406
+ const text = messageTextOf(event);
407
+ if (text !== '') state.assistantText = text;
408
+ return;
409
+ }
410
+
411
+ if (type === 'tool/call') {
412
+ state.toolCalls += 1;
413
+ state.tool = { ...toolSummaryOf(event?.data), isError: false };
414
+ // Event-level write: the sibling session sees the action while the
415
+ // turn is still running (throttled inside writeToolActivity).
416
+ stats.events += 1;
417
+ writeToolActivity(state);
418
+ return;
419
+ }
420
+
421
+ if (type === 'tool/result') {
422
+ const data = event?.data ?? {};
423
+ const message = data.message && typeof data.message === 'object' ? data.message : data;
424
+ const isError = message?.isError === true || data.isError === true;
425
+ if (isError) {
426
+ if (!state.tool && typeof data.name === 'string' && data.name.trim() !== '') {
427
+ state.tool = { ...toolSummaryOf(data), isError: true };
428
+ }
429
+ if (state.tool) {
430
+ state.tool.isError = true;
431
+ stats.events += 1;
432
+ writeToolActivity(state, { isError: true });
433
+ }
434
+ }
435
+ return;
436
+ }
437
+
438
+ if (type === 'turn/end') {
439
+ const turn = event?.data?.turn;
440
+ const reason = normalizeOrigin(event?.data?.reason?.kind);
441
+ const resolved = Number.isInteger(turn) ? turn : state.turn;
442
+ stats.turns += 1;
443
+ if (reason === 'completed' || reason === 'failed') {
444
+ writeTurn(state, reason, resolved);
445
+ } else {
446
+ stats.skipped += 1;
447
+ }
448
+ state.userText = '';
449
+ state.assistantText = '';
450
+ state.tool = null;
451
+ state.toolCalls = 0;
452
+ state.turn = Number.isInteger(resolved) ? resolved : null;
453
+ }
454
+ } catch {
455
+ stats.errors += 1;
456
+ }
457
+ }
458
+
459
+ function skip(reason) {
460
+ stats.skipped += 1;
461
+ return { injected: false, skipped: reason };
462
+ }
463
+
464
+ /**
465
+ * One pre-step attempt. Returns {injected, skipped?, entries?}; never throws.
466
+ */
467
+ async function maybeInject(payload) {
468
+ if (!enabled) return { injected: false, skipped: 'disabled' };
469
+ if (Number(payload?.step) !== 1) return { injected: false, skipped: 'not-step-1' };
470
+ const sessionId = preStepSessionId(payload);
471
+ if (sessionId === '') return skip('no-session');
472
+ if (normalizeOrigin(headerOf(payload).origin) === 'subagent') return skip('subagent');
473
+ const messages = payload?.messages;
474
+ if (!Array.isArray(messages)) return skip('no-messages');
475
+ if (!messages.some((message) => isHumanMessage(message))) return skip('empty-turn');
476
+ if (messages.some((message) => textOfMessage(message).includes(ACTIVITY_HINT_MARK))) {
477
+ return { injected: false, skipped: 'turn-already-injected' };
478
+ }
479
+
480
+ const read = store.read();
481
+ if (!read.ok) return skip('board-unreadable');
482
+ const state = readActivityState(memoryRoot, sessionId);
483
+ const boardMax = maxSeqOf(read.entries);
484
+ // Board reset (deleted/emptied/replaced-from-backup): the persisted seq-only
485
+ // cursor can sit above every current row, so `seq > lastSeenSeq` would match
486
+ // nothing forever. Falling back to 0 replays the current board instead of
487
+ // stranding the sibling (RE-R15.5). The store's high-water sidecar normally
488
+ // prevents this; this branch is the safety net for legacy/reset boards.
489
+ const resetCursor = boardMax > 0 && state.lastSeenSeq > boardMax;
490
+ if (resetCursor) stats.resets += 1;
491
+ const effectiveSeen = resetCursor ? 0 : state.lastSeenSeq;
492
+ const fresh = selectActivities(read.entries, {
493
+ selfSessionId: sessionId,
494
+ lastSeenSeq: effectiveSeen,
495
+ maxItems: cfg.maxItems,
496
+ });
497
+ if (fresh.length === 0) return { injected: false, skipped: 'no-new', reset: resetCursor };
498
+
499
+ const allowed = [];
500
+ for (const item of fresh) {
501
+ try {
502
+ const verdict = privacyScan(item.text);
503
+ if (!verdict || verdict.verdict === 'local_only') {
504
+ stats.privacy += 1;
505
+ continue;
506
+ }
507
+ } catch {
508
+ stats.privacy += 1;
509
+ continue;
510
+ }
511
+ allowed.push(item);
512
+ }
513
+ if (allowed.length === 0) return { injected: false, skipped: 'privacy' };
514
+
515
+ const rendered = renderActivityHint(allowed, {
516
+ maxChars: cfg.injectMaxChars,
517
+ maxItems: cfg.maxItems,
518
+ nowMs: now().getTime(),
519
+ });
520
+ if (rendered.text === '') return skip('budget-empty');
521
+
522
+ const createUserMessage = await resolveCreateUserMessage(cfg.createUserMessage);
523
+ if (typeof createUserMessage !== 'function') return skip('no-create-user-message');
524
+ let message;
525
+ try {
526
+ message = assertInjectionSource(createUserMessage({
527
+ role: 'user',
528
+ content: [{ type: 'text', text: rendered.text }],
529
+ source: injectionSource(),
530
+ }));
531
+ } catch {
532
+ return skip('create-message-failed');
533
+ }
534
+ const index = insertionIndex(messages);
535
+ if (index <= 0) return skip('no-human-message');
536
+ try {
537
+ messages.splice(index, 0, message);
538
+ } catch {
539
+ return skip('splice-failed');
540
+ }
541
+
542
+ const nextState = {
543
+ ...state,
544
+ lastSeenSeq: Math.max(effectiveSeen, maxSeqOf(read.entries)),
545
+ lastInjectedSeq: maxSeqOf(allowed),
546
+ lastInjectedAt: now().toISOString(),
547
+ injectedTurns: state.injectedTurns + 1,
548
+ resets: state.resets + (resetCursor ? 1 : 0),
549
+ lastResetAt: resetCursor ? now().toISOString() : state.lastResetAt,
550
+ updatedAt: now().toISOString(),
551
+ };
552
+ try {
553
+ writeJsonAtomic(activityStateFile(memoryRoot, sessionId), nextState);
554
+ } catch {
555
+ /* state is an optimization; a failed write costs a repeat, not a turn */
556
+ }
557
+ stats.injected += 1;
558
+ return {
559
+ injected: true,
560
+ sessionId,
561
+ chars: rendered.chars,
562
+ reset: resetCursor,
563
+ items: allowed.map((item) => ({ seq: item.seq, sessionId: item.sessionId, kind: item.kind })),
564
+ text: rendered.text,
565
+ };
566
+ }
567
+
568
+ function install(ctx) {
569
+ if (!enabled || !ctx || typeof ctx.on !== 'function') return false;
570
+ try {
571
+ const dispose = ctx.on('session/event', (session, event) => onSessionEvent(session, event));
572
+ if (typeof dispose === 'function') disposers.push(dispose);
573
+ } catch (error) {
574
+ stats.errors += 1;
575
+ logger(`[activity] session/event 监听注册失败:${error?.code ?? error?.message ?? error}`);
576
+ }
577
+ try {
578
+ const dispose = ctx.on('agent/pre-step', async (payload, next) => {
579
+ try {
580
+ await maybeInject(payload);
581
+ } catch {
582
+ stats.errors += 1;
583
+ }
584
+ return typeof next === 'function' ? next() : undefined;
585
+ });
586
+ if (typeof dispose === 'function') disposers.push(dispose);
587
+ } catch (error) {
588
+ stats.errors += 1;
589
+ logger(`[activity] agent/pre-step 监听注册失败:${error?.code ?? error?.message ?? error}`);
590
+ }
591
+ return true;
592
+ }
593
+
594
+ function dispose() {
595
+ while (disposers.length > 0) {
596
+ const disposer = disposers.pop();
597
+ try {
598
+ disposer();
599
+ } catch {
600
+ /* already disposed */
601
+ }
602
+ }
603
+ sessions.clear();
604
+ refreshes.clear();
605
+ }
606
+
607
+ return {
608
+ enabled,
609
+ memoryRoot,
610
+ config: cfg,
611
+ stats,
612
+ store,
613
+ install,
614
+ dispose,
615
+ onSessionEvent,
616
+ maybeInject,
617
+ readState: (sessionId) => (enabled ? readActivityState(memoryRoot, sessionId) : emptyActivityState(sessionId)),
618
+ boardFile: store?.boardFile ?? null,
619
+ };
620
+ }
621
+
622
+ /**
623
+ * Register the two hooks for the feature switch. A disabled stream registers
624
+ * nothing and touches no file; a broken registration is a warning only.
625
+ */
626
+ export function registerActivityStream(ctx, cfg = {}, options = {}) {
627
+ const stream = createActivityStream({
628
+ enabled: cfg.enabled === true,
629
+ memoryRoot: options.memoryRoot,
630
+ injectMaxChars: cfg.injectMaxChars,
631
+ maxItems: cfg.maxItems,
632
+ createUserMessage: cfg.createUserMessage,
633
+ now: options.now,
634
+ privacyScan: options.privacyScan,
635
+ logger: options.logger,
636
+ });
637
+ if (!stream.enabled) return stream;
638
+ stream.install(ctx);
639
+ if (typeof ctx?.effect === 'function') {
640
+ try {
641
+ ctx.effect(() => () => stream.dispose(), 'longlive.shared-brain-activity');
642
+ } catch {
643
+ /* a fake context may not own an effect scope */
644
+ }
645
+ }
646
+ return stream;
647
+ }
648
+
649
+ export { ACTIVITY_HINT_MARK, ACTIVITY_REFRESH_THROTTLE_MS, shortSessionId, activityBoardFile };