sentinelayer-cli 0.9.0 → 0.9.3
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/agents/backend/tools/timeout-audit.js +33 -17
- package/src/agents/devtestbot/runner.js +11 -5
- package/src/commands/session.js +566 -32
- package/src/legacy-cli.js +1 -1
- package/src/scan/generator.js +1 -1
- package/src/session/coordination-guidance.js +2 -1
- package/src/session/event-identity.js +139 -0
- package/src/session/listener.js +96 -2
- package/src/session/live-source.js +11 -2
- package/src/session/mentions.js +130 -0
- package/src/session/recap.js +35 -0
- package/src/session/remote-hydrate.js +252 -8
- package/src/session/store.js +116 -1
- package/src/session/stream.js +17 -7
- package/src/session/sync.js +375 -26
- package/src/session/title-sync.js +107 -0
|
@@ -18,11 +18,188 @@
|
|
|
18
18
|
* sync, and vice-versa.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { pollHumanMessages, pollSessionEvents } from "./sync.js";
|
|
22
|
-
import { appendToStream } from "./stream.js";
|
|
21
|
+
import { listSessionsFromApi, pollHumanMessages, pollSessionEvents } from "./sync.js";
|
|
22
|
+
import { appendToStream, readStream } from "./stream.js";
|
|
23
|
+
import {
|
|
24
|
+
createSession,
|
|
25
|
+
getSession,
|
|
26
|
+
isSessionCacheExpired,
|
|
27
|
+
refreshSessionCacheForRemoteActivity,
|
|
28
|
+
} from "./store.js";
|
|
23
29
|
import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
30
|
+
import {
|
|
31
|
+
addSessionEventIdentityKeys,
|
|
32
|
+
sessionEventHasKnownIdentity,
|
|
33
|
+
} from "./event-identity.js";
|
|
24
34
|
|
|
25
35
|
const EVENTS_CURSOR_SUFFIX = "events";
|
|
36
|
+
const DEFAULT_EVENT_PAGE_LIMIT = 200;
|
|
37
|
+
const DEFAULT_MAX_EVENT_PAGES = 25;
|
|
38
|
+
|
|
39
|
+
async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } = {}) {
|
|
40
|
+
const knownKeys = new Set();
|
|
41
|
+
const events = await readStream(sessionId, { targetPath, tail: 0 }).catch(() => []);
|
|
42
|
+
for (const event of events) {
|
|
43
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
44
|
+
}
|
|
45
|
+
return knownKeys;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
|
|
49
|
+
const existing = await getSession(sessionId, { targetPath });
|
|
50
|
+
let remoteStatus = "";
|
|
51
|
+
let remoteSession = null;
|
|
52
|
+
const remoteList = await listSessionsFromApi({
|
|
53
|
+
targetPath,
|
|
54
|
+
includeArchived: true,
|
|
55
|
+
limit: 200,
|
|
56
|
+
}).catch(() => null);
|
|
57
|
+
if (remoteList?.ok) {
|
|
58
|
+
const match = (remoteList.sessions || []).find((entry) => entry?.sessionId === sessionId);
|
|
59
|
+
remoteSession = match || null;
|
|
60
|
+
remoteStatus = String(match?.archiveStatus || match?.status || "").trim().toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
if (existing) {
|
|
63
|
+
const existingStatus = String(existing.status || "").trim().toLowerCase();
|
|
64
|
+
const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
|
|
65
|
+
const remoteAllowsRefresh =
|
|
66
|
+
["active", "pending"].includes(remoteStatus) || (!remoteStatus && !locallyClosedByStatus);
|
|
67
|
+
if (isSessionCacheExpired(existing) && remoteAllowsRefresh) {
|
|
68
|
+
const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
|
|
69
|
+
targetPath,
|
|
70
|
+
title: remoteSession?.title || "",
|
|
71
|
+
lastInteractionAt:
|
|
72
|
+
remoteSession?.lastInteractionAt ||
|
|
73
|
+
remoteSession?.lastActivityAt ||
|
|
74
|
+
remoteSession?.updatedAt ||
|
|
75
|
+
remoteSession?.createdAt ||
|
|
76
|
+
"",
|
|
77
|
+
});
|
|
78
|
+
return {
|
|
79
|
+
materialized: false,
|
|
80
|
+
refreshed: Boolean(refreshed),
|
|
81
|
+
session: refreshed || existing,
|
|
82
|
+
remoteStatus,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { materialized: false, refreshed: false, session: existing, remoteStatus };
|
|
86
|
+
}
|
|
87
|
+
const created = await createSession({
|
|
88
|
+
targetPath,
|
|
89
|
+
sessionId,
|
|
90
|
+
title: `remote-${String(sessionId).slice(0, 8)}`,
|
|
91
|
+
});
|
|
92
|
+
return { materialized: true, refreshed: false, session: created, remoteStatus };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
|
|
96
|
+
const relayedEvents = Array.isArray(events) ? events : [];
|
|
97
|
+
if (relayedEvents.length === 0) return true;
|
|
98
|
+
return relayedEvents.every((event) => sessionEventHasKnownIdentity(event, successfulKeys));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function markPostKillEvent(event = {}) {
|
|
102
|
+
if (!event || typeof event !== "object" || Array.isArray(event)) return event;
|
|
103
|
+
const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
|
|
104
|
+
? event.payload
|
|
105
|
+
: {};
|
|
106
|
+
return {
|
|
107
|
+
...event,
|
|
108
|
+
_post_kill: true,
|
|
109
|
+
payload: {
|
|
110
|
+
...payload,
|
|
111
|
+
_post_kill: true,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function normalizePositiveInteger(value, fallbackValue) {
|
|
117
|
+
if (value === undefined || value === null || String(value).trim() === "") {
|
|
118
|
+
return fallbackValue;
|
|
119
|
+
}
|
|
120
|
+
const normalized = Number(value);
|
|
121
|
+
if (!Number.isFinite(normalized) || normalized <= 0) {
|
|
122
|
+
return fallbackValue;
|
|
123
|
+
}
|
|
124
|
+
return Math.floor(normalized);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function pollSessionEventPages({
|
|
128
|
+
sessionId,
|
|
129
|
+
targetPath,
|
|
130
|
+
since,
|
|
131
|
+
_pollEvents,
|
|
132
|
+
limit,
|
|
133
|
+
maxPages,
|
|
134
|
+
forceCircuitProbe = false,
|
|
135
|
+
}) {
|
|
136
|
+
const normalizedLimit = Math.max(
|
|
137
|
+
1,
|
|
138
|
+
Math.min(DEFAULT_EVENT_PAGE_LIMIT, normalizePositiveInteger(limit, DEFAULT_EVENT_PAGE_LIMIT)),
|
|
139
|
+
);
|
|
140
|
+
const normalizedMaxPages = Math.max(
|
|
141
|
+
1,
|
|
142
|
+
Math.min(100, normalizePositiveInteger(maxPages, DEFAULT_MAX_EVENT_PAGES)),
|
|
143
|
+
);
|
|
144
|
+
const events = [];
|
|
145
|
+
let cursor = typeof since === "string" && since.trim() ? since.trim() : null;
|
|
146
|
+
let reason = "";
|
|
147
|
+
let pageCount = 0;
|
|
148
|
+
|
|
149
|
+
for (let page = 0; page < normalizedMaxPages; page += 1) {
|
|
150
|
+
const result = await _pollEvents(sessionId, {
|
|
151
|
+
targetPath,
|
|
152
|
+
since: cursor,
|
|
153
|
+
limit: normalizedLimit,
|
|
154
|
+
forceCircuitProbe,
|
|
155
|
+
});
|
|
156
|
+
pageCount += 1;
|
|
157
|
+
if (!result?.ok) {
|
|
158
|
+
return {
|
|
159
|
+
ok: events.length > 0,
|
|
160
|
+
reason: result?.reason || "poll_failed",
|
|
161
|
+
events,
|
|
162
|
+
cursor,
|
|
163
|
+
pageCount,
|
|
164
|
+
complete: false,
|
|
165
|
+
truncated: events.length > 0,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const pageEvents = Array.isArray(result.events) ? result.events : [];
|
|
170
|
+
events.push(...pageEvents);
|
|
171
|
+
const nextCursor =
|
|
172
|
+
typeof result.cursor === "string" && result.cursor.trim() ? result.cursor.trim() : cursor;
|
|
173
|
+
const progressed = nextCursor && nextCursor !== cursor;
|
|
174
|
+
cursor = nextCursor || cursor;
|
|
175
|
+
|
|
176
|
+
if (pageEvents.length < normalizedLimit) {
|
|
177
|
+
return {
|
|
178
|
+
ok: true,
|
|
179
|
+
reason: "",
|
|
180
|
+
events,
|
|
181
|
+
cursor,
|
|
182
|
+
pageCount,
|
|
183
|
+
complete: true,
|
|
184
|
+
truncated: false,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
if (!progressed) {
|
|
188
|
+
reason = "cursor_not_advanced";
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return {
|
|
194
|
+
ok: events.length > 0,
|
|
195
|
+
reason: reason || "max_event_pages_reached",
|
|
196
|
+
events,
|
|
197
|
+
cursor,
|
|
198
|
+
pageCount,
|
|
199
|
+
complete: false,
|
|
200
|
+
truncated: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
26
203
|
|
|
27
204
|
/**
|
|
28
205
|
* Fetch new human messages for a session, append them to the local
|
|
@@ -46,6 +223,10 @@ export async function hydrateSessionFromRemote({
|
|
|
46
223
|
_poll = pollHumanMessages,
|
|
47
224
|
_pollEvents = pollSessionEvents,
|
|
48
225
|
_append = appendToStream,
|
|
226
|
+
_ensureLocalSession = ensureLocalSessionShell,
|
|
227
|
+
probeOpenCircuit = true,
|
|
228
|
+
eventPageLimit = DEFAULT_EVENT_PAGE_LIMIT,
|
|
229
|
+
maxEventPages = DEFAULT_MAX_EVENT_PAGES,
|
|
49
230
|
} = {}) {
|
|
50
231
|
if (!sessionId || typeof sessionId !== "string") {
|
|
51
232
|
return {
|
|
@@ -74,25 +255,56 @@ export async function hydrateSessionFromRemote({
|
|
|
74
255
|
// Run both pollers in parallel — they hit different endpoints and
|
|
75
256
|
// are independent. A human-only poll stays fast even when the
|
|
76
257
|
// events poll is heavy.
|
|
77
|
-
|
|
258
|
+
let [humanResult, eventsResult] = await Promise.all([
|
|
78
259
|
_poll(sessionId, { targetPath, since: humanCursor }),
|
|
79
|
-
|
|
260
|
+
pollSessionEventPages({
|
|
261
|
+
sessionId,
|
|
262
|
+
targetPath,
|
|
263
|
+
since: eventsCursor,
|
|
264
|
+
_pollEvents,
|
|
265
|
+
limit: eventPageLimit,
|
|
266
|
+
maxPages: maxEventPages,
|
|
267
|
+
}),
|
|
80
268
|
]);
|
|
81
269
|
|
|
270
|
+
if (
|
|
271
|
+
probeOpenCircuit &&
|
|
272
|
+
humanResult?.reason === "circuit_breaker_open" &&
|
|
273
|
+
eventsResult?.reason === "circuit_breaker_open"
|
|
274
|
+
) {
|
|
275
|
+
[humanResult, eventsResult] = await Promise.all([
|
|
276
|
+
_poll(sessionId, { targetPath, since: humanCursor, forceCircuitProbe: true }),
|
|
277
|
+
pollSessionEventPages({
|
|
278
|
+
sessionId,
|
|
279
|
+
targetPath,
|
|
280
|
+
since: eventsCursor,
|
|
281
|
+
_pollEvents,
|
|
282
|
+
limit: eventPageLimit,
|
|
283
|
+
maxPages: maxEventPages,
|
|
284
|
+
forceCircuitProbe: true,
|
|
285
|
+
}),
|
|
286
|
+
]);
|
|
287
|
+
}
|
|
288
|
+
|
|
82
289
|
// Dedup across sources — both endpoints can return the same event
|
|
83
290
|
// (e.g. a human relay event). Cursor values are unique per event.
|
|
84
291
|
const seenCursors = new Set();
|
|
292
|
+
const seenKeys = new Set();
|
|
85
293
|
const merged = [];
|
|
86
294
|
for (const e of humanResult?.events || []) {
|
|
87
295
|
const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
|
|
88
296
|
if (c && seenCursors.has(c)) continue;
|
|
297
|
+
if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
|
|
89
298
|
if (c) seenCursors.add(c);
|
|
299
|
+
addSessionEventIdentityKeys(seenKeys, e);
|
|
90
300
|
merged.push(e);
|
|
91
301
|
}
|
|
92
302
|
for (const e of eventsResult?.events || []) {
|
|
93
303
|
const c = (e && typeof e === "object" && typeof e.cursor === "string") ? e.cursor : "";
|
|
94
304
|
if (c && seenCursors.has(c)) continue;
|
|
305
|
+
if (sessionEventHasKnownIdentity(e, seenKeys)) continue;
|
|
95
306
|
if (c) seenCursors.add(c);
|
|
307
|
+
addSessionEventIdentityKeys(seenKeys, e);
|
|
96
308
|
merged.push(e);
|
|
97
309
|
}
|
|
98
310
|
|
|
@@ -112,10 +324,33 @@ export async function hydrateSessionFromRemote({
|
|
|
112
324
|
}
|
|
113
325
|
|
|
114
326
|
let relayed = 0;
|
|
115
|
-
|
|
327
|
+
let materializedLocalSession = false;
|
|
328
|
+
let remoteStatus = "";
|
|
329
|
+
const successfulRelayKeys =
|
|
330
|
+
merged.length > 0 ? await readExistingRelayKeys(sessionId, { targetPath }) : new Set();
|
|
331
|
+
const newEvents = successfulRelayKeys.size > 0
|
|
332
|
+
? merged.filter((event) => !sessionEventHasKnownIdentity(event, successfulRelayKeys))
|
|
333
|
+
: merged;
|
|
334
|
+
if (newEvents.length > 0) {
|
|
335
|
+
try {
|
|
336
|
+
const localSession = await _ensureLocalSession(sessionId, { targetPath });
|
|
337
|
+
materializedLocalSession = Boolean(localSession?.materialized);
|
|
338
|
+
remoteStatus = String(localSession?.remoteStatus || "").trim().toLowerCase();
|
|
339
|
+
} catch {
|
|
340
|
+
// Keep the old degraded behavior: append attempts below will
|
|
341
|
+
// fail visibly in the returned counters, but remote polling still
|
|
342
|
+
// returns a structured result.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
const appendEvents =
|
|
346
|
+
remoteStatus && !["active", "pending"].includes(remoteStatus)
|
|
347
|
+
? newEvents.map((event) => markPostKillEvent(event))
|
|
348
|
+
: newEvents;
|
|
349
|
+
for (const event of appendEvents) {
|
|
116
350
|
try {
|
|
117
|
-
await _append(sessionId, event, { targetPath });
|
|
351
|
+
await _append(sessionId, event, { targetPath, syncRemote: false });
|
|
118
352
|
relayed += 1;
|
|
353
|
+
addSessionEventIdentityKeys(successfulRelayKeys, event);
|
|
119
354
|
} catch {
|
|
120
355
|
// Append errors are observable via the stream but should not
|
|
121
356
|
// abort the rest of the batch — partial relay is still progress.
|
|
@@ -123,11 +358,13 @@ export async function hydrateSessionFromRemote({
|
|
|
123
358
|
}
|
|
124
359
|
|
|
125
360
|
let persistedCursor = false;
|
|
126
|
-
|
|
361
|
+
const humanCursorSafe = sourceFullyRelayed(humanResult?.events || [], successfulRelayKeys);
|
|
362
|
+
const eventsCursorSafe = sourceFullyRelayed(eventsResult?.events || [], successfulRelayKeys);
|
|
363
|
+
if (humanCursorSafe && typeof humanResult?.cursor === "string" && humanResult.cursor.trim()) {
|
|
127
364
|
const result = await writeSyncCursor(sessionId, humanResult.cursor, { targetPath }).catch(() => null);
|
|
128
365
|
persistedCursor = Boolean(result && result.written);
|
|
129
366
|
}
|
|
130
|
-
if (typeof eventsResult?.cursor === "string" && eventsResult.cursor.trim()) {
|
|
367
|
+
if (eventsCursorSafe && typeof eventsResult?.cursor === "string" && eventsResult.cursor.trim()) {
|
|
131
368
|
await writeSyncCursor(sessionId, eventsResult.cursor, {
|
|
132
369
|
targetPath,
|
|
133
370
|
suffix: EVENTS_CURSOR_SUFFIX,
|
|
@@ -145,5 +382,12 @@ export async function hydrateSessionFromRemote({
|
|
|
145
382
|
eventsRelayed: (eventsResult?.events || []).length,
|
|
146
383
|
eventsCursor:
|
|
147
384
|
typeof eventsResult?.cursor === "string" ? eventsResult.cursor : eventsCursor || null,
|
|
385
|
+
eventsPageCount: Number(eventsResult?.pageCount || 0),
|
|
386
|
+
eventsBackfillComplete: Boolean(eventsResult?.complete !== false),
|
|
387
|
+
eventsBackfillTruncated: Boolean(eventsResult?.truncated),
|
|
388
|
+
eventsBackfillReason: eventsResult?.complete === false ? eventsResult?.reason || "" : "",
|
|
389
|
+
materializedLocalSession,
|
|
390
|
+
localAppendComplete: humanCursorSafe && eventsCursorSafe,
|
|
391
|
+
remoteStatus: remoteStatus || null,
|
|
148
392
|
};
|
|
149
393
|
}
|
package/src/session/store.js
CHANGED
|
@@ -183,6 +183,31 @@ function normalizeSharedResources(raw = {}, { nowIso = new Date().toISOString()
|
|
|
183
183
|
};
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
function normalizeRemoteTitleSync(raw = {}, { nowIso = new Date().toISOString() } = {}) {
|
|
187
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const title = normalizeString(raw.title);
|
|
191
|
+
const lastAttemptAt = raw.lastAttemptAt
|
|
192
|
+
? normalizeIsoTimestamp(raw.lastAttemptAt, nowIso)
|
|
193
|
+
: null;
|
|
194
|
+
const lastSyncedAt = raw.lastSyncedAt
|
|
195
|
+
? normalizeIsoTimestamp(raw.lastSyncedAt, nowIso)
|
|
196
|
+
: null;
|
|
197
|
+
const failureReason = normalizeString(raw.failureReason) || null;
|
|
198
|
+
const pending = Boolean(raw.pending);
|
|
199
|
+
if (!pending && !title && !lastAttemptAt && !lastSyncedAt && !failureReason) {
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return {
|
|
203
|
+
pending,
|
|
204
|
+
title: title || null,
|
|
205
|
+
lastAttemptAt,
|
|
206
|
+
lastSyncedAt,
|
|
207
|
+
failureReason,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
186
211
|
function normalizeTemplateAgent(raw = {}) {
|
|
187
212
|
const source = raw && typeof raw === "object" ? raw : {};
|
|
188
213
|
return {
|
|
@@ -357,12 +382,14 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
|
|
|
357
382
|
archiveStatus: normalizeString(raw.archiveStatus) || "pending",
|
|
358
383
|
codebaseContext: normalizeCodebaseContext(raw.codebaseContext || {}),
|
|
359
384
|
sharedResources: normalizeSharedResources(raw.sharedResources || {}, { nowIso }),
|
|
385
|
+
remoteTitleSync: normalizeRemoteTitleSync(raw.remoteTitleSync || null, { nowIso }),
|
|
360
386
|
template: normalizeSessionTemplate(raw.template || null),
|
|
361
387
|
};
|
|
362
388
|
}
|
|
363
389
|
|
|
364
390
|
function isExpired(metadata, nowIso = new Date().toISOString()) {
|
|
365
|
-
|
|
391
|
+
const status = normalizeSessionStatus(metadata?.status);
|
|
392
|
+
if (!metadata || status === SESSION_STATUS_EXPIRED || status === SESSION_STATUS_ARCHIVED) {
|
|
366
393
|
return true;
|
|
367
394
|
}
|
|
368
395
|
const expiryEpoch = Date.parse(normalizeIsoTimestamp(metadata.expiresAt, nowIso));
|
|
@@ -373,6 +400,10 @@ function isExpired(metadata, nowIso = new Date().toISOString()) {
|
|
|
373
400
|
return nowEpoch >= expiryEpoch;
|
|
374
401
|
}
|
|
375
402
|
|
|
403
|
+
export function isSessionCacheExpired(metadata, nowIso = new Date().toISOString()) {
|
|
404
|
+
return isExpired(metadata, nowIso);
|
|
405
|
+
}
|
|
406
|
+
|
|
376
407
|
function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString()) {
|
|
377
408
|
return {
|
|
378
409
|
sessionId: metadata.sessionId,
|
|
@@ -391,6 +422,7 @@ function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString())
|
|
|
391
422
|
s3Path: metadata.s3Path,
|
|
392
423
|
codebaseContext: metadata.codebaseContext,
|
|
393
424
|
sharedResources: metadata.sharedResources,
|
|
425
|
+
remoteTitleSync: metadata.remoteTitleSync,
|
|
394
426
|
template: metadata.template,
|
|
395
427
|
};
|
|
396
428
|
}
|
|
@@ -464,6 +496,7 @@ export async function createSession({
|
|
|
464
496
|
archiveStatus: "pending",
|
|
465
497
|
codebaseContext,
|
|
466
498
|
sharedResources: normalizeSharedResources({}, { nowIso }),
|
|
499
|
+
remoteTitleSync: null,
|
|
467
500
|
template: normalizeSessionTemplate(template),
|
|
468
501
|
},
|
|
469
502
|
{
|
|
@@ -498,6 +531,88 @@ export async function updateSessionTitle(
|
|
|
498
531
|
return buildSessionPayload(saved, loaded.paths, nowIso);
|
|
499
532
|
}
|
|
500
533
|
|
|
534
|
+
export async function refreshSessionCacheForRemoteActivity(
|
|
535
|
+
sessionId,
|
|
536
|
+
{
|
|
537
|
+
targetPath = process.cwd(),
|
|
538
|
+
title = "",
|
|
539
|
+
ttlSeconds = DEFAULT_TTL_SECONDS,
|
|
540
|
+
lastInteractionAt = "",
|
|
541
|
+
nowIso = new Date().toISOString(),
|
|
542
|
+
} = {}
|
|
543
|
+
) {
|
|
544
|
+
const loaded = await loadMetadata(sessionId, { targetPath });
|
|
545
|
+
if (!loaded) {
|
|
546
|
+
return null;
|
|
547
|
+
}
|
|
548
|
+
const normalizedTtlSeconds = normalizePositiveInteger(ttlSeconds, DEFAULT_TTL_SECONDS);
|
|
549
|
+
const remoteInteractionIso = normalizeIsoTimestamp(
|
|
550
|
+
lastInteractionAt,
|
|
551
|
+
loaded.metadata.lastInteractionAt || loaded.metadata.createdAt || nowIso
|
|
552
|
+
);
|
|
553
|
+
const currentInteractionEpoch = Date.parse(
|
|
554
|
+
normalizeIsoTimestamp(loaded.metadata.lastInteractionAt, loaded.metadata.createdAt || nowIso)
|
|
555
|
+
);
|
|
556
|
+
const remoteInteractionEpoch = Date.parse(remoteInteractionIso);
|
|
557
|
+
const lastInteractionIso =
|
|
558
|
+
Number.isFinite(remoteInteractionEpoch) &&
|
|
559
|
+
(!Number.isFinite(currentInteractionEpoch) || remoteInteractionEpoch > currentInteractionEpoch)
|
|
560
|
+
? remoteInteractionIso
|
|
561
|
+
: normalizeIsoTimestamp(loaded.metadata.lastInteractionAt, nowIso);
|
|
562
|
+
|
|
563
|
+
const metadata = {
|
|
564
|
+
...loaded.metadata,
|
|
565
|
+
updatedAt: nowIso,
|
|
566
|
+
expiresAt: toIsoAfterSeconds(nowIso, normalizedTtlSeconds),
|
|
567
|
+
ttlSeconds: normalizedTtlSeconds,
|
|
568
|
+
status: SESSION_STATUS_ACTIVE,
|
|
569
|
+
expiredAt: null,
|
|
570
|
+
lastInteractionAt: lastInteractionIso,
|
|
571
|
+
};
|
|
572
|
+
const normalizedTitle = normalizeString(title);
|
|
573
|
+
if (normalizedTitle) {
|
|
574
|
+
metadata.title = normalizedTitle;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const saved = await saveMetadata(metadata, loaded.paths);
|
|
578
|
+
return buildSessionPayload(saved, loaded.paths, nowIso);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
export async function recordSessionRemoteTitleSync(
|
|
582
|
+
sessionId,
|
|
583
|
+
{
|
|
584
|
+
targetPath = process.cwd(),
|
|
585
|
+
title = "",
|
|
586
|
+
pending = false,
|
|
587
|
+
failureReason = "",
|
|
588
|
+
lastAttemptAt = "",
|
|
589
|
+
lastSyncedAt = "",
|
|
590
|
+
} = {}
|
|
591
|
+
) {
|
|
592
|
+
const loaded = await loadMetadata(sessionId, { targetPath });
|
|
593
|
+
if (!loaded) {
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
const nowIso = new Date().toISOString();
|
|
597
|
+
const metadata = {
|
|
598
|
+
...loaded.metadata,
|
|
599
|
+
remoteTitleSync: normalizeRemoteTitleSync(
|
|
600
|
+
{
|
|
601
|
+
pending: Boolean(pending),
|
|
602
|
+
title: normalizeString(title) || normalizeString(loaded.metadata.title) || null,
|
|
603
|
+
lastAttemptAt: lastAttemptAt || nowIso,
|
|
604
|
+
lastSyncedAt:
|
|
605
|
+
!pending && !normalizeString(failureReason) ? lastSyncedAt || nowIso : lastSyncedAt || null,
|
|
606
|
+
failureReason: normalizeString(failureReason) || null,
|
|
607
|
+
},
|
|
608
|
+
{ nowIso },
|
|
609
|
+
),
|
|
610
|
+
updatedAt: nowIso,
|
|
611
|
+
};
|
|
612
|
+
const saved = await saveMetadata(metadata, loaded.paths);
|
|
613
|
+
return buildSessionPayload(saved, loaded.paths, nowIso);
|
|
614
|
+
}
|
|
615
|
+
|
|
501
616
|
export async function getSession(sessionId, { targetPath = process.cwd() } = {}) {
|
|
502
617
|
const loaded = await loadMetadata(sessionId, { targetPath });
|
|
503
618
|
if (!loaded) {
|
package/src/session/stream.js
CHANGED
|
@@ -3,6 +3,7 @@ import process from "node:process";
|
|
|
3
3
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
4
4
|
|
|
5
5
|
import { createAgentEvent, normalizeAgentEvent } from "../events/schema.js";
|
|
6
|
+
import { enrichEventWithMentions } from "./mentions.js";
|
|
6
7
|
import { resolveSessionPaths } from "./paths.js";
|
|
7
8
|
import { redactEventPayload } from "./redact.js";
|
|
8
9
|
import { syncSessionEventToApi } from "./sync.js";
|
|
@@ -223,7 +224,7 @@ function filterBySince(events = [], since) {
|
|
|
223
224
|
export async function appendToStream(
|
|
224
225
|
sessionId,
|
|
225
226
|
event,
|
|
226
|
-
{ targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS } = {}
|
|
227
|
+
{ targetPath = process.cwd(), maxEvents = DEFAULT_MAX_STREAM_EVENTS, syncRemote = true } = {}
|
|
227
228
|
) {
|
|
228
229
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
229
230
|
const metadata = await readSessionMetadata(paths);
|
|
@@ -235,7 +236,14 @@ export async function appendToStream(
|
|
|
235
236
|
}
|
|
236
237
|
|
|
237
238
|
const rawEvent = materializeCanonicalEvent(paths.sessionId, event);
|
|
238
|
-
|
|
239
|
+
// Parse `@<name>` mentions out of the message text and surface them
|
|
240
|
+
// as payload.to + payload.mentions so the listener (sl session listen)
|
|
241
|
+
// and Senti daemon can route to/notify the addressed agent. Pure +
|
|
242
|
+
// idempotent — if the caller already supplied an explicit `to/recipient`
|
|
243
|
+
// we leave it alone. Runs before redaction so the redactor sees a
|
|
244
|
+
// stable shape.
|
|
245
|
+
const enrichedEvent = enrichEventWithMentions(rawEvent);
|
|
246
|
+
const canonicalEvent = redactEventPayload(enrichedEvent);
|
|
239
247
|
const nowIso = new Date().toISOString();
|
|
240
248
|
const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_MAX_STREAM_EVENTS);
|
|
241
249
|
|
|
@@ -252,10 +260,12 @@ export async function appendToStream(
|
|
|
252
260
|
await releaseLock(paths.lockPath);
|
|
253
261
|
}
|
|
254
262
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
263
|
+
if (syncRemote) {
|
|
264
|
+
// Best-effort dashboard sync. Never block local stream durability on API state.
|
|
265
|
+
void syncSessionEventToApi(paths.sessionId, canonicalEvent, {
|
|
266
|
+
targetPath,
|
|
267
|
+
}).catch(() => {});
|
|
268
|
+
}
|
|
259
269
|
|
|
260
270
|
return canonicalEvent;
|
|
261
271
|
}
|
|
@@ -320,7 +330,7 @@ export async function* tailStream(
|
|
|
320
330
|
}
|
|
321
331
|
|
|
322
332
|
try {
|
|
323
|
-
await sleep(normalizedPollMs, null, signal ? { signal } :
|
|
333
|
+
await sleep(normalizedPollMs, null, signal ? { signal, ref: false } : { ref: false });
|
|
324
334
|
} catch (error) {
|
|
325
335
|
if (error && typeof error === "object" && error.name === "AbortError") {
|
|
326
336
|
return;
|