@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.
- package/CHANGELOG.md +40 -4
- package/QUICKSTART.md +10 -10
- package/README.en.md +4 -3
- package/README.md +10 -4
- package/SPEC.md +2 -2
- package/cordis.patch.yml +9 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/CONFIG.md +33 -6
- package/docs/DEV.md +3 -3
- package/docs/FAQ.md +4 -4
- package/docs/FEATURES.md +36 -5
- package/docs/OPERATIONS.md +1 -1
- package/docs/QUICKSTART.md +3 -3
- package/docs/README.en.md +3 -2
- package/docs/TROUBLESHOOTING.md +3 -3
- package/package.json +13 -8
- package/src/activity/index.js +649 -0
- package/src/activity/model.js +341 -0
- package/src/activity/store.js +625 -0
- package/src/compaction/engine-core.js +144 -97
- package/src/config.js +29 -2
- package/src/features.js +26 -0
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/live.js +22 -2
- package/src/recall/privacy.mjs +105 -16
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/service.mjs +27 -11
- package/test/activity-realtime.test.mjs +959 -0
- package/test/activity-stream.test.mjs +512 -0
- package/test/autobind.test.mjs +25 -11
- package/test/config-schema.test.mjs +6 -0
- package/test/feature-flags.test.mjs +17 -0
- package/test/fixtures/activity-append-child.mjs +29 -0
- package/test/hot-truncate.test.mjs +113 -0
- package/test/live.test.mjs +6 -3
- package/test/packaging.test.mjs +7 -5
- package/test/router-background/router.test.mjs +44 -12
- package/test/run-rc.mjs +16 -0
- package/test/safety-defaults.test.mjs +5 -3
- package/test/state-truth.test.mjs +937 -0
- 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 };
|