sentinelayer-cli 0.8.5 → 0.8.7
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/package.json +1 -1
- package/src/commands/session.js +92 -13
- package/src/session/live-source.js +308 -0
- package/src/session/preview.js +91 -0
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -49,8 +49,10 @@ import {
|
|
|
49
49
|
recordSessionProvisionedIdentities,
|
|
50
50
|
} from "../session/store.js";
|
|
51
51
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
52
|
+
import { readSessionPreview } from "../session/preview.js";
|
|
52
53
|
import { syncSessionMetadataToApi } from "../session/sync.js";
|
|
53
54
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
55
|
+
import { mergeLiveSources } from "../session/live-source.js";
|
|
54
56
|
import {
|
|
55
57
|
buildDashboardUrl,
|
|
56
58
|
buildTemplateLaunchPlan,
|
|
@@ -452,7 +454,11 @@ export function registerSessionCommand(program) {
|
|
|
452
454
|
.command("read <sessionId>")
|
|
453
455
|
.description("Read recent session messages")
|
|
454
456
|
.option("--tail <n>", "Number of recent events", "20")
|
|
455
|
-
.option("--follow", "Continuously follow new events")
|
|
457
|
+
.option("--follow", "Continuously follow new events (local fs poll)")
|
|
458
|
+
.option(
|
|
459
|
+
"--live",
|
|
460
|
+
"Subscribe to SSE + fs.watch combined source (replaces --follow). Same-machine peers via fs.watch, remote peers via SSE; events deduped by id.",
|
|
461
|
+
)
|
|
456
462
|
.option(
|
|
457
463
|
"--remote",
|
|
458
464
|
"Hydrate from the SentinelLayer API before reading (pulls web-posted messages into the local NDJSON)",
|
|
@@ -515,6 +521,49 @@ export function registerSessionCommand(program) {
|
|
|
515
521
|
return;
|
|
516
522
|
}
|
|
517
523
|
|
|
524
|
+
if (options.live) {
|
|
525
|
+
if (!emitJson) {
|
|
526
|
+
console.log(
|
|
527
|
+
pc.gray(
|
|
528
|
+
`Live-tailing ${normalizedSessionId} (SSE + fs.watch)… Ctrl+C to stop.`,
|
|
529
|
+
),
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
const ac = new AbortController();
|
|
533
|
+
const onSigint = () => ac.abort();
|
|
534
|
+
process.on("SIGINT", onSigint);
|
|
535
|
+
const session = await resolveActiveAuthSession({
|
|
536
|
+
cwd: targetPath,
|
|
537
|
+
env: process.env,
|
|
538
|
+
autoRotate: false,
|
|
539
|
+
}).catch(() => null);
|
|
540
|
+
const apiBaseUrl = session?.apiUrl || "";
|
|
541
|
+
const token = session?.token || "";
|
|
542
|
+
try {
|
|
543
|
+
for await (const item of mergeLiveSources({
|
|
544
|
+
sessionId: normalizedSessionId,
|
|
545
|
+
targetPath,
|
|
546
|
+
apiBaseUrl: apiBaseUrl || undefined,
|
|
547
|
+
token: token || undefined,
|
|
548
|
+
signal: ac.signal,
|
|
549
|
+
})) {
|
|
550
|
+
if (item.event) {
|
|
551
|
+
if (emitJson) {
|
|
552
|
+
console.log(JSON.stringify({ source: item.source, event: item.event }));
|
|
553
|
+
} else {
|
|
554
|
+
const sourceTag = item.source === "sse" ? pc.cyan("[sse]") : pc.gray("[fs] ");
|
|
555
|
+
console.log(`${sourceTag} ${formatEventLine(item.event)}`);
|
|
556
|
+
}
|
|
557
|
+
} else if (item.error && !emitJson) {
|
|
558
|
+
console.log(pc.yellow(`(${item.source} stream: ${item.error})`));
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} finally {
|
|
562
|
+
process.removeListener("SIGINT", onSigint);
|
|
563
|
+
}
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
|
|
518
567
|
if (!emitJson) {
|
|
519
568
|
console.log(pc.gray(`Following session ${normalizedSessionId}... Press Ctrl+C to stop.`));
|
|
520
569
|
}
|
|
@@ -847,35 +896,65 @@ export function registerSessionCommand(program) {
|
|
|
847
896
|
|
|
848
897
|
session
|
|
849
898
|
.command("history")
|
|
850
|
-
.description(
|
|
899
|
+
.description(
|
|
900
|
+
"Past conversations with a one-line preview of the most recent message (alias for `session list --include-archived` + previews)",
|
|
901
|
+
)
|
|
851
902
|
.option("--limit <n>", "Maximum sessions to return", "50")
|
|
903
|
+
.option("--no-preview", "Skip the per-session preview lookup")
|
|
852
904
|
.option("--path <path>", "Workspace path for sessions", ".")
|
|
853
905
|
.option("--json", "Emit machine-readable output")
|
|
854
906
|
.action(async (options, command) => {
|
|
855
907
|
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
856
908
|
const limit = parsePositiveInteger(options.limit, "limit", 50);
|
|
909
|
+
const wantPreview = options.preview !== false;
|
|
857
910
|
const sessions = await listAllSessions({ targetPath });
|
|
858
911
|
const trimmed = shouldEmitJson(options, command) ? sessions : sessions.slice(0, limit);
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
912
|
+
|
|
913
|
+
let previews = new Map();
|
|
914
|
+
if (wantPreview && trimmed.length > 0) {
|
|
915
|
+
const entries = await Promise.all(
|
|
916
|
+
trimmed.map(async (item) => [
|
|
917
|
+
item.sessionId,
|
|
918
|
+
await readSessionPreview(item.sessionId, { targetPath }),
|
|
919
|
+
]),
|
|
920
|
+
);
|
|
921
|
+
previews = new Map(entries);
|
|
922
|
+
}
|
|
923
|
+
|
|
865
924
|
if (shouldEmitJson(options, command)) {
|
|
925
|
+
const payload = {
|
|
926
|
+
command: "session history",
|
|
927
|
+
targetPath,
|
|
928
|
+
count: sessions.length,
|
|
929
|
+
sessions: trimmed.map((item) => ({
|
|
930
|
+
...item,
|
|
931
|
+
preview: previews.get(item.sessionId) || null,
|
|
932
|
+
})),
|
|
933
|
+
};
|
|
866
934
|
console.log(JSON.stringify(payload, null, 2));
|
|
867
935
|
return;
|
|
868
936
|
}
|
|
937
|
+
|
|
869
938
|
if (sessions.length === 0) {
|
|
870
939
|
console.log(pc.yellow("No sessions in cache."));
|
|
871
940
|
return;
|
|
872
941
|
}
|
|
873
942
|
for (const item of trimmed) {
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
)
|
|
943
|
+
const archive = item.archiveStatus.padEnd(8);
|
|
944
|
+
const head =
|
|
945
|
+
`${archive} ${item.sessionId} created=${item.createdAt}` +
|
|
946
|
+
(item.archivedAt ? ` archived=${item.archivedAt}` : "");
|
|
947
|
+
if (!wantPreview) {
|
|
948
|
+
console.log(head);
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
const preview = previews.get(item.sessionId);
|
|
952
|
+
if (preview && preview.message) {
|
|
953
|
+
const speaker = preview.agentId ? `${preview.agentId}: ` : "";
|
|
954
|
+
console.log(`${head}\n ${pc.gray(`${speaker}${preview.message}`)}`);
|
|
955
|
+
} else {
|
|
956
|
+
console.log(`${head}\n ${pc.gray("(no messages yet)")}`);
|
|
957
|
+
}
|
|
879
958
|
}
|
|
880
959
|
if (sessions.length > trimmed.length) {
|
|
881
960
|
console.log(
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live session-event source — composes `fs.watch` (instant local notify
|
|
3
|
+
* when the NDJSON file changes) and SSE (`/api/v1/sessions/<id>/stream`,
|
|
4
|
+
* server-pushed updates) into a single async iterator.
|
|
5
|
+
*
|
|
6
|
+
* The two lanes give us the WebRTC-like behavior the user asked about
|
|
7
|
+
* without the WebRTC operational tax: same-machine peers see each
|
|
8
|
+
* other's writes through `fs.watch` immediately; remote peers receive
|
|
9
|
+
* via SSE the moment the API persists. A single stream emits both, with
|
|
10
|
+
* dedup by event id so the same event seen on both lanes only surfaces
|
|
11
|
+
* once.
|
|
12
|
+
*
|
|
13
|
+
* Tests inject `_watch`, `_sse`, and `_readEvents` so the iterator can
|
|
14
|
+
* be exercised hermetically; production uses `node:fs` watch + native
|
|
15
|
+
* fetch streaming.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
20
|
+
|
|
21
|
+
import { resolveSessionPaths } from "./paths.js";
|
|
22
|
+
import { readStream } from "./stream.js";
|
|
23
|
+
|
|
24
|
+
const DEFAULT_RECONNECT_BACKOFF_MS = 2_000;
|
|
25
|
+
const MAX_RECONNECT_BACKOFF_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
function eventKey(event) {
|
|
28
|
+
if (!event || typeof event !== "object") return null;
|
|
29
|
+
if (event.id) return `id:${event.id}`;
|
|
30
|
+
if (event.eventId) return `id:${event.eventId}`;
|
|
31
|
+
const ts = event.ts || event.timestamp;
|
|
32
|
+
const kind = event.event || event.type;
|
|
33
|
+
if (ts && kind) return `${ts}::${kind}`;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Watch a session's NDJSON file with `fs.watch`. Whenever the file
|
|
39
|
+
* changes (append-only writes happen on every event), re-read the tail
|
|
40
|
+
* and emit any events the consumer hasn't seen yet. Falls back to a
|
|
41
|
+
* 500 ms poll on platforms where `fs.watch` is unreliable (some
|
|
42
|
+
* Windows + network mounts) — controlled by `_watch` for tests.
|
|
43
|
+
*
|
|
44
|
+
* Async generator yields `{ source: "fs", event }`.
|
|
45
|
+
*/
|
|
46
|
+
export async function* watchLocalStream({
|
|
47
|
+
sessionId,
|
|
48
|
+
targetPath,
|
|
49
|
+
signal,
|
|
50
|
+
initialTail = 50,
|
|
51
|
+
_watch = fs.watch,
|
|
52
|
+
_readEvents = readStream,
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (!sessionId) return;
|
|
55
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
56
|
+
let lastTs = null;
|
|
57
|
+
|
|
58
|
+
// Replay the tail first so any caller getting the iterator catches
|
|
59
|
+
// up with the in-flight context before live events start arriving.
|
|
60
|
+
const initial = await _readEvents(sessionId, { targetPath, tail: initialTail });
|
|
61
|
+
for (const event of initial) {
|
|
62
|
+
const candidate = event.ts || event.timestamp;
|
|
63
|
+
if (candidate) lastTs = candidate;
|
|
64
|
+
yield { source: "fs", event };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
let pendingResolve = null;
|
|
68
|
+
let pendingPromise = null;
|
|
69
|
+
const queue = [];
|
|
70
|
+
|
|
71
|
+
function notify() {
|
|
72
|
+
if (pendingResolve) {
|
|
73
|
+
const r = pendingResolve;
|
|
74
|
+
pendingResolve = null;
|
|
75
|
+
pendingPromise = null;
|
|
76
|
+
r();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let watcher = null;
|
|
81
|
+
try {
|
|
82
|
+
watcher = _watch(paths.streamPath, { persistent: false }, () => notify());
|
|
83
|
+
} catch {
|
|
84
|
+
// If watch can't attach (file missing yet, locked filesystem), we
|
|
85
|
+
// fall back to a 500ms poll so the iterator still makes progress.
|
|
86
|
+
watcher = {
|
|
87
|
+
close() {},
|
|
88
|
+
};
|
|
89
|
+
(async () => {
|
|
90
|
+
while (!signal?.aborted) {
|
|
91
|
+
await sleep(500);
|
|
92
|
+
notify();
|
|
93
|
+
}
|
|
94
|
+
})();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const aborted = () => Boolean(signal?.aborted);
|
|
98
|
+
if (signal) signal.addEventListener("abort", () => notify(), { once: true });
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
while (!aborted()) {
|
|
102
|
+
// Wait for any change notification.
|
|
103
|
+
pendingPromise = new Promise((resolve) => {
|
|
104
|
+
pendingResolve = resolve;
|
|
105
|
+
});
|
|
106
|
+
await pendingPromise;
|
|
107
|
+
if (aborted()) break;
|
|
108
|
+
|
|
109
|
+
const events = await _readEvents(sessionId, { targetPath, tail: 0, since: lastTs });
|
|
110
|
+
for (const event of events) {
|
|
111
|
+
const candidate = event.ts || event.timestamp;
|
|
112
|
+
if (lastTs && candidate && candidate <= lastTs) continue;
|
|
113
|
+
if (candidate) lastTs = candidate;
|
|
114
|
+
queue.push({ source: "fs", event });
|
|
115
|
+
}
|
|
116
|
+
while (queue.length > 0) {
|
|
117
|
+
yield queue.shift();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} finally {
|
|
121
|
+
try {
|
|
122
|
+
watcher.close();
|
|
123
|
+
} catch {
|
|
124
|
+
/* swallow */
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Subscribe to the API's SSE stream for a session. Emits each parsed
|
|
131
|
+
* data: line as `{ source: "sse", event }`. Auto-reconnects on
|
|
132
|
+
* connection drop with exponential backoff capped at 30s.
|
|
133
|
+
*
|
|
134
|
+
* `_sseFetch` defaults to `fetch` but tests can stub it.
|
|
135
|
+
*/
|
|
136
|
+
export async function* watchRemoteStream({
|
|
137
|
+
apiBaseUrl,
|
|
138
|
+
sessionId,
|
|
139
|
+
token,
|
|
140
|
+
signal,
|
|
141
|
+
_sseFetch = fetch,
|
|
142
|
+
reconnectBackoffMs = DEFAULT_RECONNECT_BACKOFF_MS,
|
|
143
|
+
} = {}) {
|
|
144
|
+
if (!apiBaseUrl || !sessionId || !token) return;
|
|
145
|
+
const endpoint = `${apiBaseUrl.replace(/\/+$/, "")}/api/v1/sessions/${encodeURIComponent(
|
|
146
|
+
sessionId,
|
|
147
|
+
)}/stream`;
|
|
148
|
+
let backoff = reconnectBackoffMs;
|
|
149
|
+
|
|
150
|
+
while (!signal?.aborted) {
|
|
151
|
+
let response;
|
|
152
|
+
try {
|
|
153
|
+
response = await _sseFetch(endpoint, {
|
|
154
|
+
method: "GET",
|
|
155
|
+
headers: {
|
|
156
|
+
Authorization: `Bearer ${token}`,
|
|
157
|
+
Accept: "text/event-stream",
|
|
158
|
+
},
|
|
159
|
+
signal,
|
|
160
|
+
});
|
|
161
|
+
} catch (err) {
|
|
162
|
+
if (signal?.aborted) return;
|
|
163
|
+
yield { source: "sse", error: String(err?.message || err) };
|
|
164
|
+
await sleep(backoff);
|
|
165
|
+
backoff = Math.min(backoff * 2, MAX_RECONNECT_BACKOFF_MS);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (!response || !response.ok || !response.body) {
|
|
170
|
+
yield { source: "sse", error: `HTTP ${response?.status || "?"}` };
|
|
171
|
+
await sleep(backoff);
|
|
172
|
+
backoff = Math.min(backoff * 2, MAX_RECONNECT_BACKOFF_MS);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
backoff = reconnectBackoffMs;
|
|
177
|
+
const reader = response.body.getReader();
|
|
178
|
+
const decoder = new TextDecoder();
|
|
179
|
+
let buffer = "";
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
while (!signal?.aborted) {
|
|
183
|
+
const { value, done } = await reader.read();
|
|
184
|
+
if (done) break;
|
|
185
|
+
buffer += decoder.decode(value, { stream: true });
|
|
186
|
+
const frames = buffer.split(/\n\n/);
|
|
187
|
+
buffer = frames.pop() || "";
|
|
188
|
+
for (const frame of frames) {
|
|
189
|
+
for (const line of frame.split(/\r?\n/)) {
|
|
190
|
+
if (!line.startsWith("data:")) continue;
|
|
191
|
+
const payload = line.slice(5).trim();
|
|
192
|
+
if (!payload || payload === "[done]") continue;
|
|
193
|
+
try {
|
|
194
|
+
const event = JSON.parse(payload);
|
|
195
|
+
yield { source: "sse", event };
|
|
196
|
+
} catch {
|
|
197
|
+
yield { source: "sse", raw: payload };
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
} finally {
|
|
203
|
+
try {
|
|
204
|
+
await reader.cancel();
|
|
205
|
+
} catch {
|
|
206
|
+
/* swallow */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (signal?.aborted) return;
|
|
211
|
+
await sleep(backoff);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Compose `fs.watch` and SSE into one event stream. Each emitted event
|
|
217
|
+
* carries its `source` so consumers can tell which lane saw it first;
|
|
218
|
+
* we dedup by event id so the same event arriving on both lanes only
|
|
219
|
+
* surfaces once.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} params
|
|
222
|
+
* @param {string} params.sessionId
|
|
223
|
+
* @param {string} [params.targetPath]
|
|
224
|
+
* @param {string} [params.apiBaseUrl]
|
|
225
|
+
* @param {string} [params.token]
|
|
226
|
+
* @param {AbortSignal} [params.signal]
|
|
227
|
+
* @returns {AsyncIterable<{source: "fs"|"sse", event?: object, raw?: string, error?: string}>}
|
|
228
|
+
*/
|
|
229
|
+
export async function* mergeLiveSources({
|
|
230
|
+
sessionId,
|
|
231
|
+
targetPath,
|
|
232
|
+
apiBaseUrl,
|
|
233
|
+
token,
|
|
234
|
+
signal,
|
|
235
|
+
_localIterator,
|
|
236
|
+
_remoteIterator,
|
|
237
|
+
} = {}) {
|
|
238
|
+
if (!sessionId) return;
|
|
239
|
+
|
|
240
|
+
const localIterable = _localIterator
|
|
241
|
+
? _localIterator
|
|
242
|
+
: watchLocalStream({ sessionId, targetPath, signal });
|
|
243
|
+
const remoteIterable =
|
|
244
|
+
_remoteIterator || (apiBaseUrl && token)
|
|
245
|
+
? _remoteIterator
|
|
246
|
+
? _remoteIterator
|
|
247
|
+
: watchRemoteStream({ apiBaseUrl, sessionId, token, signal })
|
|
248
|
+
: null;
|
|
249
|
+
|
|
250
|
+
const seen = new Set();
|
|
251
|
+
const queue = [];
|
|
252
|
+
let pending = null;
|
|
253
|
+
|
|
254
|
+
const wakeUp = () => {
|
|
255
|
+
if (pending) {
|
|
256
|
+
const r = pending;
|
|
257
|
+
pending = null;
|
|
258
|
+
r();
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
async function pump(iterable) {
|
|
263
|
+
if (!iterable) return;
|
|
264
|
+
try {
|
|
265
|
+
for await (const item of iterable) {
|
|
266
|
+
queue.push(item);
|
|
267
|
+
wakeUp();
|
|
268
|
+
if (signal?.aborted) break;
|
|
269
|
+
}
|
|
270
|
+
} catch (err) {
|
|
271
|
+
queue.push({ source: "merge", error: String(err?.message || err) });
|
|
272
|
+
wakeUp();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
pump(localIterable);
|
|
277
|
+
if (remoteIterable) pump(remoteIterable);
|
|
278
|
+
|
|
279
|
+
// Make sure abort wakes the iterator promptly so the consumer doesn't
|
|
280
|
+
// hang waiting on a `pending` promise that nothing is going to
|
|
281
|
+
// resolve once the upstream sources finish.
|
|
282
|
+
if (signal) signal.addEventListener("abort", () => wakeUp(), { once: true });
|
|
283
|
+
|
|
284
|
+
while (!signal?.aborted) {
|
|
285
|
+
if (queue.length === 0) {
|
|
286
|
+
await new Promise((resolve) => {
|
|
287
|
+
pending = resolve;
|
|
288
|
+
});
|
|
289
|
+
if (signal?.aborted) break;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const item = queue.shift();
|
|
293
|
+
if (item.event) {
|
|
294
|
+
const key = eventKey(item.event);
|
|
295
|
+
if (key) {
|
|
296
|
+
if (seen.has(key)) continue;
|
|
297
|
+
seen.add(key);
|
|
298
|
+
if (seen.size > 5000) {
|
|
299
|
+
// bound memory — older keys roll out
|
|
300
|
+
const trimmed = Array.from(seen).slice(-2500);
|
|
301
|
+
seen.clear();
|
|
302
|
+
for (const k of trimmed) seen.add(k);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
yield item;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session message preview — used by `slc session history` to show
|
|
3
|
+
* the last meaningful line of each past conversation, ChatGPT-style.
|
|
4
|
+
*
|
|
5
|
+
* "Meaningful" filters out heartbeats / agent_join / file-lock churn
|
|
6
|
+
* so the preview doesn't get drowned in machine traffic.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readStream } from "./stream.js";
|
|
10
|
+
|
|
11
|
+
const PREVIEW_EVENTS = new Set([
|
|
12
|
+
"session_message",
|
|
13
|
+
"session_say",
|
|
14
|
+
"agent_response",
|
|
15
|
+
"human_relay",
|
|
16
|
+
"daemon_alert",
|
|
17
|
+
"session_admin_kill",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const HEAD_LIMIT = 40;
|
|
21
|
+
const PREVIEW_TAIL_SCAN = 50;
|
|
22
|
+
|
|
23
|
+
function trim(value, limit = HEAD_LIMIT) {
|
|
24
|
+
const text = String(value == null ? "" : value).trim();
|
|
25
|
+
if (!text) return "";
|
|
26
|
+
if (text.length <= limit) return text;
|
|
27
|
+
return `${text.slice(0, Math.max(0, limit - 1))}…`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Pick the most recent user-visible message from an event list. Returns
|
|
32
|
+
* the head of its body and the speaker so the caller can render
|
|
33
|
+
* `<agentId>: <message>`.
|
|
34
|
+
*
|
|
35
|
+
* @param {Array<object>} events
|
|
36
|
+
* @returns {{ts: string|null, agentId: string|null, kind: string|null, message: string|null}}
|
|
37
|
+
*/
|
|
38
|
+
export function pickLatestPreview(events = []) {
|
|
39
|
+
if (!Array.isArray(events) || events.length === 0) {
|
|
40
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
41
|
+
}
|
|
42
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
43
|
+
const event = events[i] || {};
|
|
44
|
+
const kind = String(event.event || event.type || "").trim();
|
|
45
|
+
if (!kind || !PREVIEW_EVENTS.has(kind)) continue;
|
|
46
|
+
const payload = event.payload && typeof event.payload === "object" ? event.payload : {};
|
|
47
|
+
const text =
|
|
48
|
+
payload.message ||
|
|
49
|
+
payload.response ||
|
|
50
|
+
payload.alert ||
|
|
51
|
+
payload.reason ||
|
|
52
|
+
payload.text;
|
|
53
|
+
if (!text) continue;
|
|
54
|
+
return {
|
|
55
|
+
ts: event.ts || event.timestamp || null,
|
|
56
|
+
agentId:
|
|
57
|
+
(event.agent && event.agent.id) ||
|
|
58
|
+
event.agentId ||
|
|
59
|
+
payload.agentId ||
|
|
60
|
+
null,
|
|
61
|
+
kind,
|
|
62
|
+
message: trim(text),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Lift a preview line for a single session by tailing the stream.
|
|
70
|
+
* Failures are non-fatal — missing stream / parse errors yield a null
|
|
71
|
+
* preview rather than throwing, so the history listing stays resilient
|
|
72
|
+
* across mixed-state caches.
|
|
73
|
+
*
|
|
74
|
+
* @param {string} sessionId
|
|
75
|
+
* @param {{targetPath?: string, tail?: number}} [options]
|
|
76
|
+
* @returns {Promise<{ts: string|null, agentId: string|null, kind: string|null, message: string|null}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function readSessionPreview(
|
|
79
|
+
sessionId,
|
|
80
|
+
{ targetPath = process.cwd(), tail = PREVIEW_TAIL_SCAN } = {},
|
|
81
|
+
) {
|
|
82
|
+
if (!sessionId) {
|
|
83
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const events = await readStream(sessionId, { targetPath, tail });
|
|
87
|
+
return pickLatestPreview(events);
|
|
88
|
+
} catch {
|
|
89
|
+
return { ts: null, agentId: null, kind: null, message: null };
|
|
90
|
+
}
|
|
91
|
+
}
|