sentinelayer-cli 0.9.2 → 0.9.4
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 +52 -51
- package/src/session/recap.js +199 -2
- package/src/session/remote-hydrate.js +34 -5
- package/src/session/store.js +53 -1
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -44,9 +44,11 @@ import {
|
|
|
44
44
|
createSession,
|
|
45
45
|
DEFAULT_TTL_SECONDS,
|
|
46
46
|
getSession,
|
|
47
|
+
isSessionCacheExpired,
|
|
47
48
|
listActiveSessions,
|
|
48
49
|
listAllSessions,
|
|
49
50
|
recordSessionProvisionedIdentities,
|
|
51
|
+
refreshSessionCacheForRemoteActivity,
|
|
50
52
|
updateSessionTitle,
|
|
51
53
|
} from "../session/store.js";
|
|
52
54
|
import { appendToStream, readStream, tailStream } from "../session/stream.js";
|
|
@@ -330,11 +332,44 @@ function formatRelativeAge(isoTimestamp) {
|
|
|
330
332
|
|
|
331
333
|
async function ensureLocalSessionForRemoteCommand(
|
|
332
334
|
sessionId,
|
|
333
|
-
{ targetPath, title = "", skipRemoteProbe = false } = {},
|
|
335
|
+
{ targetPath, title = "", skipRemoteProbe = false, remoteSession = null } = {},
|
|
334
336
|
) {
|
|
335
337
|
const existing = await getSession(sessionId, { targetPath });
|
|
336
338
|
if (existing) {
|
|
337
|
-
|
|
339
|
+
if (!isSessionCacheExpired(existing)) {
|
|
340
|
+
return { materialized: false, refreshed: false, session: existing };
|
|
341
|
+
}
|
|
342
|
+
const existingStatus = normalizeString(existing.status).toLowerCase();
|
|
343
|
+
const locallyClosedByStatus = existingStatus === "expired" || existingStatus === "archived";
|
|
344
|
+
if (locallyClosedByStatus && !skipRemoteProbe) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`Session '${sessionId}' is ${existingStatus} locally; run \`sl session join ${sessionId}\` to verify remote access before posting.`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
let access = { accessible: Boolean(skipRemoteProbe), reason: "" };
|
|
351
|
+
if (!skipRemoteProbe) {
|
|
352
|
+
access = await probeSessionAccess(sessionId, { targetPath }).catch((error) => ({
|
|
353
|
+
accessible: false,
|
|
354
|
+
reason: normalizeString(error?.message) || "probe_failed",
|
|
355
|
+
}));
|
|
356
|
+
}
|
|
357
|
+
if (!access?.accessible) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Session '${sessionId}' is expired locally and remote access failed (${access?.reason || "unknown"}).`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const refreshed = await refreshSessionCacheForRemoteActivity(sessionId, {
|
|
364
|
+
targetPath,
|
|
365
|
+
title,
|
|
366
|
+
lastInteractionAt:
|
|
367
|
+
normalizeString(remoteSession?.lastInteractionAt) ||
|
|
368
|
+
normalizeString(remoteSession?.lastActivityAt) ||
|
|
369
|
+
normalizeString(remoteSession?.updatedAt) ||
|
|
370
|
+
normalizeString(remoteSession?.createdAt),
|
|
371
|
+
});
|
|
372
|
+
return { materialized: false, refreshed: Boolean(refreshed), session: refreshed || existing };
|
|
338
373
|
}
|
|
339
374
|
// `skipRemoteProbe` is set by callers that have already verified the session
|
|
340
375
|
// via `verifyRemoteSession` (the singleton GET) — re-probing the legacy
|
|
@@ -356,7 +391,7 @@ async function ensureLocalSessionForRemoteCommand(
|
|
|
356
391
|
sessionId,
|
|
357
392
|
title: normalizeString(title) || `remote-${String(sessionId).slice(0, 8)}`,
|
|
358
393
|
});
|
|
359
|
-
return { materialized: true, session: created };
|
|
394
|
+
return { materialized: true, refreshed: false, session: created };
|
|
360
395
|
}
|
|
361
396
|
|
|
362
397
|
async function ensureWorkspaceSession({
|
|
@@ -470,56 +505,16 @@ function normalizeAgentId(value, fallbackValue = "cli-user") {
|
|
|
470
505
|
return normalized || fallbackValue;
|
|
471
506
|
}
|
|
472
507
|
|
|
473
|
-
//
|
|
474
|
-
//
|
|
475
|
-
// `
|
|
476
|
-
//
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
// we need to figure out a fingerprint for them somehow.. maybe at joining
|
|
480
|
-
// we ask for name?" — auth-derived names are the cleanest deterministic
|
|
481
|
-
// fingerprint we already have. Fall through to "cli-user" only if the
|
|
482
|
-
// CLI is genuinely unauthenticated (CI fixture, fresh checkout).
|
|
483
|
-
let _cachedAuthAgentId = undefined; // undefined = not yet resolved
|
|
484
|
-
async function _resolveAuthAgentId(targetPath) {
|
|
485
|
-
if (_cachedAuthAgentId !== undefined) return _cachedAuthAgentId;
|
|
486
|
-
try {
|
|
487
|
-
const auth = await resolveActiveAuthSession({
|
|
488
|
-
cwd: targetPath || process.cwd(),
|
|
489
|
-
env: process.env,
|
|
490
|
-
autoRotate: false,
|
|
491
|
-
});
|
|
492
|
-
const username = normalizeString(auth?.user?.githubUsername).toLowerCase();
|
|
493
|
-
if (username) {
|
|
494
|
-
_cachedAuthAgentId = `human-${username.replace(/[^a-z0-9._-]+/g, "-")}`;
|
|
495
|
-
return _cachedAuthAgentId;
|
|
496
|
-
}
|
|
497
|
-
const email = normalizeString(auth?.user?.email).toLowerCase();
|
|
498
|
-
if (email) {
|
|
499
|
-
const local = email.split("@")[0].replace(/[^a-z0-9._-]+/g, "-");
|
|
500
|
-
if (local) {
|
|
501
|
-
_cachedAuthAgentId = `human-${local}`;
|
|
502
|
-
return _cachedAuthAgentId;
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
} catch {
|
|
506
|
-
/* unauthenticated → fall through */
|
|
507
|
-
}
|
|
508
|
-
_cachedAuthAgentId = "";
|
|
509
|
-
return "";
|
|
508
|
+
// Preserve the literal default identity for `session say`. This command is
|
|
509
|
+
// often used by agents as a low-friction relay; silently rewriting the default
|
|
510
|
+
// `cli-user` to the authenticated human makes a forgotten --agent flag look
|
|
511
|
+
// like the workspace owner authored the message.
|
|
512
|
+
export function resolveSessionSayAgentId(value) {
|
|
513
|
+
return normalizeAgentId(value, "cli-user");
|
|
510
514
|
}
|
|
511
515
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
// supplied a name keep round-tripping verbatim.
|
|
515
|
-
async function defaultAgentId(value, targetPath) {
|
|
516
|
-
const explicit = normalizeString(value);
|
|
517
|
-
if (explicit && explicit.toLowerCase() !== "cli-user") {
|
|
518
|
-
return normalizeAgentId(value, "cli-user");
|
|
519
|
-
}
|
|
520
|
-
const authId = await _resolveAuthAgentId(targetPath);
|
|
521
|
-
if (authId) return authId;
|
|
522
|
-
return normalizeAgentId(value, "cli-user");
|
|
516
|
+
async function defaultAgentId(value, _targetPath) {
|
|
517
|
+
return resolveSessionSayAgentId(value);
|
|
523
518
|
}
|
|
524
519
|
|
|
525
520
|
async function runWithConcurrency(items = [], concurrency = 1, worker = async () => null) {
|
|
@@ -933,6 +928,7 @@ export function registerSessionCommand(program) {
|
|
|
933
928
|
targetPath,
|
|
934
929
|
title: normalizeString(remoteSession.title),
|
|
935
930
|
skipRemoteProbe: true,
|
|
931
|
+
remoteSession,
|
|
936
932
|
});
|
|
937
933
|
const payload = {
|
|
938
934
|
command: "session ensure",
|
|
@@ -942,6 +938,7 @@ export function registerSessionCommand(program) {
|
|
|
942
938
|
resumed: true,
|
|
943
939
|
attached: true,
|
|
944
940
|
materializedLocalSession: localSession.materialized,
|
|
941
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
945
942
|
verificationSource: verification.source,
|
|
946
943
|
dashboardUrl: buildDashboardUrl(explicitSessionId),
|
|
947
944
|
};
|
|
@@ -1145,6 +1142,7 @@ export function registerSessionCommand(program) {
|
|
|
1145
1142
|
targetPath,
|
|
1146
1143
|
title: normalizeString(remoteSession.title),
|
|
1147
1144
|
skipRemoteProbe: true,
|
|
1145
|
+
remoteSession,
|
|
1148
1146
|
});
|
|
1149
1147
|
|
|
1150
1148
|
const explicitAgent = normalizeString(options.agent);
|
|
@@ -1195,6 +1193,7 @@ export function registerSessionCommand(program) {
|
|
|
1195
1193
|
status: joined.status,
|
|
1196
1194
|
joinedAt: joined.joinedAt,
|
|
1197
1195
|
materializedLocalSession: localSession.materialized,
|
|
1196
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1198
1197
|
verificationSource: verification.source,
|
|
1199
1198
|
eventCount: Number.isFinite(eventCount) ? eventCount : 0,
|
|
1200
1199
|
agentCount: Number.isFinite(agentCount) ? agentCount : 0,
|
|
@@ -1277,6 +1276,7 @@ export function registerSessionCommand(program) {
|
|
|
1277
1276
|
agentId,
|
|
1278
1277
|
event: persisted,
|
|
1279
1278
|
materializedLocalSession: localSession.materialized,
|
|
1279
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1280
1280
|
remoteSync: remoteSync || undefined,
|
|
1281
1281
|
};
|
|
1282
1282
|
if (shouldEmitJson(options, command)) {
|
|
@@ -1364,6 +1364,7 @@ export function registerSessionCommand(program) {
|
|
|
1364
1364
|
agentId,
|
|
1365
1365
|
event: persisted,
|
|
1366
1366
|
materializedLocalSession: localSession.materialized,
|
|
1367
|
+
refreshedLocalSession: Boolean(localSession.refreshed),
|
|
1367
1368
|
remoteSync,
|
|
1368
1369
|
};
|
|
1369
1370
|
if (shouldEmitJson(options, command)) {
|
package/src/session/recap.js
CHANGED
|
@@ -13,6 +13,14 @@ const DEFAULT_RECAP_MAX_EVENTS = 100;
|
|
|
13
13
|
const DEFAULT_RECAP_INTERVAL_MS = 300_000;
|
|
14
14
|
const DEFAULT_RECAP_INACTIVITY_MS = 600_000;
|
|
15
15
|
const DEFAULT_RECAP_ACTIVITY_THRESHOLD = 5;
|
|
16
|
+
const DEFAULT_TASK_SUMMARY_LIMIT = 3;
|
|
17
|
+
const ACTIVE_TASK_STATUSES = new Set(["PENDING", "ACCEPTED", "BLOCKED"]);
|
|
18
|
+
const TASK_STATUS_KEYS = {
|
|
19
|
+
PENDING: "pending",
|
|
20
|
+
ACCEPTED: "accepted",
|
|
21
|
+
COMPLETED: "completed",
|
|
22
|
+
BLOCKED: "blocked",
|
|
23
|
+
};
|
|
16
24
|
|
|
17
25
|
const ACTIVE_RECAP_EMITTERS = new Map();
|
|
18
26
|
|
|
@@ -160,6 +168,117 @@ async function readPendingTasks(sessionId, { forAgentId = "", targetPath = proce
|
|
|
160
168
|
}
|
|
161
169
|
}
|
|
162
170
|
|
|
171
|
+
function normalizeTaskStatus(value) {
|
|
172
|
+
const normalized = normalizeString(value).toUpperCase();
|
|
173
|
+
return Object.prototype.hasOwnProperty.call(TASK_STATUS_KEYS, normalized)
|
|
174
|
+
? normalized
|
|
175
|
+
: "PENDING";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function taskOwner(task = {}) {
|
|
179
|
+
return (
|
|
180
|
+
normalizeString(task.toAgentId) ||
|
|
181
|
+
normalizeString(task.requestedToAgentId) ||
|
|
182
|
+
(normalizeString(task.roleFilter) ? `role:${normalizeString(task.roleFilter)}` : "") ||
|
|
183
|
+
"unassigned"
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function shortTaskText(value) {
|
|
188
|
+
const text = normalizeString(value).replace(/\s+/g, " ");
|
|
189
|
+
if (text.length <= 80) {
|
|
190
|
+
return text;
|
|
191
|
+
}
|
|
192
|
+
return `${text.slice(0, 77)}...`;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function emptyTaskLedgerSummary() {
|
|
196
|
+
return {
|
|
197
|
+
total: 0,
|
|
198
|
+
active: 0,
|
|
199
|
+
pending: 0,
|
|
200
|
+
accepted: 0,
|
|
201
|
+
blocked: 0,
|
|
202
|
+
completed: 0,
|
|
203
|
+
owners: [],
|
|
204
|
+
recent: [],
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function summarizeTaskLedger(tasks = [], { limit = DEFAULT_TASK_SUMMARY_LIMIT } = {}) {
|
|
209
|
+
const summary = emptyTaskLedgerSummary();
|
|
210
|
+
const owners = new Map();
|
|
211
|
+
const normalizedTasks = [];
|
|
212
|
+
|
|
213
|
+
for (const task of Array.isArray(tasks) ? tasks : []) {
|
|
214
|
+
if (!task || typeof task !== "object") {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const status = normalizeTaskStatus(task.status);
|
|
218
|
+
const statusKey = TASK_STATUS_KEYS[status];
|
|
219
|
+
const owner = taskOwner(task);
|
|
220
|
+
const priority = normalizeString(task.priority) || "when-free";
|
|
221
|
+
const taskId = normalizeString(task.taskId) || "task";
|
|
222
|
+
const updatedAt = normalizeIsoTimestamp(
|
|
223
|
+
task.updatedAt || task.completedAt || task.acceptedAt || task.createdAt,
|
|
224
|
+
new Date().toISOString(),
|
|
225
|
+
);
|
|
226
|
+
const record = {
|
|
227
|
+
taskId,
|
|
228
|
+
status,
|
|
229
|
+
priority,
|
|
230
|
+
owner,
|
|
231
|
+
task: shortTaskText(task.task),
|
|
232
|
+
updatedAt,
|
|
233
|
+
};
|
|
234
|
+
normalizedTasks.push(record);
|
|
235
|
+
summary.total += 1;
|
|
236
|
+
summary[statusKey] += 1;
|
|
237
|
+
|
|
238
|
+
if (ACTIVE_TASK_STATUSES.has(status)) {
|
|
239
|
+
summary.active += 1;
|
|
240
|
+
const ownerRecord = owners.get(owner) || {
|
|
241
|
+
agentId: owner,
|
|
242
|
+
active: 0,
|
|
243
|
+
pending: 0,
|
|
244
|
+
accepted: 0,
|
|
245
|
+
blocked: 0,
|
|
246
|
+
};
|
|
247
|
+
ownerRecord.active += 1;
|
|
248
|
+
ownerRecord[statusKey] += 1;
|
|
249
|
+
owners.set(owner, ownerRecord);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
summary.owners = [...owners.values()]
|
|
254
|
+
.sort((left, right) => {
|
|
255
|
+
if (right.active !== left.active) return right.active - left.active;
|
|
256
|
+
return left.agentId.localeCompare(right.agentId);
|
|
257
|
+
})
|
|
258
|
+
.slice(0, Math.max(1, limit));
|
|
259
|
+
summary.recent = normalizedTasks
|
|
260
|
+
.sort((left, right) => toEpoch(right.updatedAt) - toEpoch(left.updatedAt))
|
|
261
|
+
.slice(0, Math.max(1, limit));
|
|
262
|
+
return summary;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function readTaskLedgerSummary(
|
|
266
|
+
sessionId,
|
|
267
|
+
{ targetPath = process.cwd(), limit = DEFAULT_TASK_SUMMARY_LIMIT } = {},
|
|
268
|
+
) {
|
|
269
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
270
|
+
try {
|
|
271
|
+
const raw = await fsp.readFile(paths.tasksPath, "utf-8");
|
|
272
|
+
const parsed = JSON.parse(raw);
|
|
273
|
+
return summarizeTaskLedger(parsed?.tasks || [], { limit });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
276
|
+
return emptyTaskLedgerSummary();
|
|
277
|
+
}
|
|
278
|
+
return emptyTaskLedgerSummary();
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
163
282
|
function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
|
|
164
283
|
if (!Array.isArray(events) || events.length === 0) {
|
|
165
284
|
return 0;
|
|
@@ -181,6 +300,7 @@ function buildRecapText({
|
|
|
181
300
|
totalFindings = 0,
|
|
182
301
|
activeLocks = 0,
|
|
183
302
|
pendingTasks = 0,
|
|
303
|
+
taskLedger = emptyTaskLedgerSummary(),
|
|
184
304
|
snippets = [],
|
|
185
305
|
} = {}) {
|
|
186
306
|
const agentText =
|
|
@@ -191,13 +311,78 @@ function buildRecapText({
|
|
|
191
311
|
const lockText = `${activeLocks} file lock${activeLocks === 1 ? "" : "s"} active`;
|
|
192
312
|
const pendingText =
|
|
193
313
|
pendingTasks > 0 ? `You have ${pendingTasks} pending task${pendingTasks === 1 ? "" : "s"}.` : "";
|
|
314
|
+
const taskText = buildTaskLedgerText(taskLedger);
|
|
194
315
|
const snippetText = snippets.length > 0 ? `Recent: ${snippets.join(" | ")}` : "";
|
|
195
|
-
return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${snippetText}`.replace(
|
|
316
|
+
return `While you were away: ${agentText}. ${findingText}. ${lockText}. ${pendingText} ${taskText}. ${snippetText}`.replace(
|
|
196
317
|
/\s+/g,
|
|
197
318
|
" "
|
|
198
319
|
).trim();
|
|
199
320
|
}
|
|
200
321
|
|
|
322
|
+
function buildTaskLedgerText(taskLedger = emptyTaskLedgerSummary()) {
|
|
323
|
+
const total = Number(taskLedger.total || 0);
|
|
324
|
+
if (!total) {
|
|
325
|
+
return "Tasks: none queued";
|
|
326
|
+
}
|
|
327
|
+
const active = Number(taskLedger.active || 0);
|
|
328
|
+
const counts = [
|
|
329
|
+
`${Number(taskLedger.pending || 0)} pending`,
|
|
330
|
+
`${Number(taskLedger.accepted || 0)} accepted`,
|
|
331
|
+
`${Number(taskLedger.blocked || 0)} blocked`,
|
|
332
|
+
`${Number(taskLedger.completed || 0)} done`,
|
|
333
|
+
].join(", ");
|
|
334
|
+
const ownerText =
|
|
335
|
+
Array.isArray(taskLedger.owners) && taskLedger.owners.length > 0
|
|
336
|
+
? `Owners: ${taskLedger.owners
|
|
337
|
+
.map((owner) => {
|
|
338
|
+
const parts = [];
|
|
339
|
+
if (owner.pending) parts.push(`${owner.pending} pending`);
|
|
340
|
+
if (owner.accepted) parts.push(`${owner.accepted} accepted`);
|
|
341
|
+
if (owner.blocked) parts.push(`${owner.blocked} blocked`);
|
|
342
|
+
return `${owner.agentId} (${parts.join("/") || `${owner.active || 0} active`})`;
|
|
343
|
+
})
|
|
344
|
+
.join("; ")}`
|
|
345
|
+
: "Owners: none active";
|
|
346
|
+
const recentText =
|
|
347
|
+
Array.isArray(taskLedger.recent) && taskLedger.recent.length > 0
|
|
348
|
+
? `Recent tasks: ${taskLedger.recent
|
|
349
|
+
.map((task) => `${task.priority} ${task.status} ${task.owner}: ${task.task}`)
|
|
350
|
+
.join(" | ")}`
|
|
351
|
+
: "";
|
|
352
|
+
return [`Tasks: ${active} active of ${total} total (${counts})`, ownerText, recentText]
|
|
353
|
+
.filter(Boolean)
|
|
354
|
+
.join(". ");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Multi-agent session etiquette + read-path rules surfaced in the
|
|
358
|
+
// context_briefing payload an agent receives on first join. Web
|
|
359
|
+
// renders this as markdown (see sentinelayer-web Session.tsx
|
|
360
|
+
// SessionMessage), so headers/lists/inline code are intentional.
|
|
361
|
+
//
|
|
362
|
+
// Keep this short and operationally actionable. Anything that's
|
|
363
|
+
// purely doctrinal belongs in AGENTS.md, not the per-join briefing.
|
|
364
|
+
const AGENT_JOIN_RULES = [
|
|
365
|
+
"**Welcome to this session.** Quick rules so we coordinate cleanly:",
|
|
366
|
+
"",
|
|
367
|
+
"**Reading the room** — When you join, the recap above summarizes activity since the last quiet stretch. To read further back, run `sl session read --remote --tail 50 --json` (bump `--tail` if you need more). Do this BEFORE responding so you don't repeat questions or miss a lock-and-claim someone else already opened.",
|
|
368
|
+
"",
|
|
369
|
+
"**Polling cadence** — Poll new events at most once per 60s (`sl session listen` or `sl session read --remote --tail N`). More frequent than that wastes budget and can hit per-user rate limits. Less frequent than ~5min and peers may think you went idle.",
|
|
370
|
+
"",
|
|
371
|
+
"**Writing back** — You can use **markdown**: bold, italic, lists, fenced code, and `inline code`. The web dashboard renders it. Plain text also works. Keep posts terse and technical — link to the work, don't recap it.",
|
|
372
|
+
"",
|
|
373
|
+
"**Coordination** — Lock-and-claim before you start a scope another agent could be on. If you push back on someone's approach, cite the specific assumption you disagree with and the file:line evidence.",
|
|
374
|
+
"",
|
|
375
|
+
"**Stop conditions** — If the human asks you to stop, stop. If 60+ minutes of total session silence, stop polling.",
|
|
376
|
+
].join("\n");
|
|
377
|
+
|
|
378
|
+
function buildAgentJoinBriefingText({ recap = "", forAgent = "" } = {}) {
|
|
379
|
+
const trimmedRecap = normalizeString(recap);
|
|
380
|
+
const trimmedAgent = normalizeString(forAgent);
|
|
381
|
+
const greeting = trimmedAgent ? `**${trimmedAgent}** joined. ${trimmedRecap}` : trimmedRecap;
|
|
382
|
+
const recapBlock = greeting || "Welcome — no prior session activity to summarize yet.";
|
|
383
|
+
return `${recapBlock}\n\n---\n\n${AGENT_JOIN_RULES}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
201
386
|
function buildPeriodicText(recap = {}) {
|
|
202
387
|
const summary = recap.summary && typeof recap.summary === "object" ? recap.summary : {};
|
|
203
388
|
const elapsedMinutes = Number(summary.elapsedMinutes || 0);
|
|
@@ -206,7 +391,8 @@ function buildPeriodicText(recap = {}) {
|
|
|
206
391
|
const activeLocks = Number(summary.activeLocks || 0);
|
|
207
392
|
const lastActor = normalizeString(summary.lastActorId);
|
|
208
393
|
const actorText = lastActor ? `${lastActor} active` : "no active actor";
|
|
209
|
-
|
|
394
|
+
const taskText = buildTaskLedgerText(summary.taskLedger);
|
|
395
|
+
return `Session active for ${elapsedMinutes}m. ${activeAgents} agents. ${totalFindings} findings. ${activeLocks} locks. ${taskText}. ${actorText}.`;
|
|
210
396
|
}
|
|
211
397
|
|
|
212
398
|
export async function buildSessionRecap(
|
|
@@ -259,6 +445,9 @@ export async function buildSessionRecap(
|
|
|
259
445
|
forAgentId: normalizedForAgentId,
|
|
260
446
|
targetPath: normalizedTargetPath,
|
|
261
447
|
});
|
|
448
|
+
const taskLedger = await readTaskLedgerSummary(normalizedSessionId, {
|
|
449
|
+
targetPath: normalizedTargetPath,
|
|
450
|
+
});
|
|
262
451
|
const snippets = summarizeRecentActivity(visibleEvents, {
|
|
263
452
|
forAgentId: normalizedForAgentId,
|
|
264
453
|
limit: 2,
|
|
@@ -270,6 +459,7 @@ export async function buildSessionRecap(
|
|
|
270
459
|
totalFindings: totalFindingsCount,
|
|
271
460
|
activeLocks,
|
|
272
461
|
pendingTasks,
|
|
462
|
+
taskLedger,
|
|
273
463
|
snippets,
|
|
274
464
|
});
|
|
275
465
|
|
|
@@ -288,6 +478,7 @@ export async function buildSessionRecap(
|
|
|
288
478
|
totalFindingsCount,
|
|
289
479
|
activeLocks,
|
|
290
480
|
pendingTasksForAgent: pendingTasks,
|
|
481
|
+
taskLedger,
|
|
291
482
|
snippets,
|
|
292
483
|
elapsedMinutes,
|
|
293
484
|
lastActorId: normalizeString(latestEvent?.agent?.id || latestEvent?.agentId) || null,
|
|
@@ -303,6 +494,7 @@ export async function emitContextBriefing(
|
|
|
303
494
|
maxEvents = DEFAULT_RECAP_MAX_EVENTS,
|
|
304
495
|
targetPath = process.cwd(),
|
|
305
496
|
nowIso = new Date().toISOString(),
|
|
497
|
+
includeJoinRules = true,
|
|
306
498
|
} = {}
|
|
307
499
|
) {
|
|
308
500
|
const recap = await buildSessionRecap(sessionId, {
|
|
@@ -311,6 +503,9 @@ export async function emitContextBriefing(
|
|
|
311
503
|
targetPath,
|
|
312
504
|
nowIso,
|
|
313
505
|
});
|
|
506
|
+
const briefingMessage = includeJoinRules
|
|
507
|
+
? buildAgentJoinBriefingText({ recap: recap.text, forAgent: forAgentId })
|
|
508
|
+
: recap.text;
|
|
314
509
|
const event = createAgentEvent({
|
|
315
510
|
event: "context_briefing",
|
|
316
511
|
agentId: SENTI_AGENT_ID,
|
|
@@ -319,7 +514,9 @@ export async function emitContextBriefing(
|
|
|
319
514
|
ts: recap.generatedAt,
|
|
320
515
|
payload: {
|
|
321
516
|
forAgent: normalizeString(forAgentId) || null,
|
|
517
|
+
message: briefingMessage,
|
|
322
518
|
recap: recap.text,
|
|
519
|
+
rules: includeJoinRules ? AGENT_JOIN_RULES : null,
|
|
323
520
|
ephemeral: true,
|
|
324
521
|
style: RECAP_STYLE,
|
|
325
522
|
generatedAt: recap.generatedAt,
|
|
@@ -20,7 +20,12 @@
|
|
|
20
20
|
|
|
21
21
|
import { listSessionsFromApi, pollHumanMessages, pollSessionEvents } from "./sync.js";
|
|
22
22
|
import { appendToStream, readStream } from "./stream.js";
|
|
23
|
-
import {
|
|
23
|
+
import {
|
|
24
|
+
createSession,
|
|
25
|
+
getSession,
|
|
26
|
+
isSessionCacheExpired,
|
|
27
|
+
refreshSessionCacheForRemoteActivity,
|
|
28
|
+
} from "./store.js";
|
|
24
29
|
import { readSyncCursor, writeSyncCursor } from "./sync-cursor.js";
|
|
25
30
|
import {
|
|
26
31
|
addSessionEventIdentityKeys,
|
|
@@ -42,10 +47,8 @@ async function readExistingRelayKeys(sessionId, { targetPath = process.cwd() } =
|
|
|
42
47
|
|
|
43
48
|
async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() } = {}) {
|
|
44
49
|
const existing = await getSession(sessionId, { targetPath });
|
|
45
|
-
if (existing) {
|
|
46
|
-
return { materialized: false, session: existing };
|
|
47
|
-
}
|
|
48
50
|
let remoteStatus = "";
|
|
51
|
+
let remoteSession = null;
|
|
49
52
|
const remoteList = await listSessionsFromApi({
|
|
50
53
|
targetPath,
|
|
51
54
|
includeArchived: true,
|
|
@@ -53,14 +56,40 @@ async function ensureLocalSessionShell(sessionId, { targetPath = process.cwd() }
|
|
|
53
56
|
}).catch(() => null);
|
|
54
57
|
if (remoteList?.ok) {
|
|
55
58
|
const match = (remoteList.sessions || []).find((entry) => entry?.sessionId === sessionId);
|
|
59
|
+
remoteSession = match || null;
|
|
56
60
|
remoteStatus = String(match?.archiveStatus || match?.status || "").trim().toLowerCase();
|
|
57
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
|
+
}
|
|
58
87
|
const created = await createSession({
|
|
59
88
|
targetPath,
|
|
60
89
|
sessionId,
|
|
61
90
|
title: `remote-${String(sessionId).slice(0, 8)}`,
|
|
62
91
|
});
|
|
63
|
-
return { materialized: true, session: created, remoteStatus };
|
|
92
|
+
return { materialized: true, refreshed: false, session: created, remoteStatus };
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
function sourceFullyRelayed(events = [], successfulKeys = new Set()) {
|
package/src/session/store.js
CHANGED
|
@@ -388,7 +388,8 @@ function normalizeMetadata(raw = {}, { sessionId, targetPath, nowIso } = {}) {
|
|
|
388
388
|
}
|
|
389
389
|
|
|
390
390
|
function isExpired(metadata, nowIso = new Date().toISOString()) {
|
|
391
|
-
|
|
391
|
+
const status = normalizeSessionStatus(metadata?.status);
|
|
392
|
+
if (!metadata || status === SESSION_STATUS_EXPIRED || status === SESSION_STATUS_ARCHIVED) {
|
|
392
393
|
return true;
|
|
393
394
|
}
|
|
394
395
|
const expiryEpoch = Date.parse(normalizeIsoTimestamp(metadata.expiresAt, nowIso));
|
|
@@ -399,6 +400,10 @@ function isExpired(metadata, nowIso = new Date().toISOString()) {
|
|
|
399
400
|
return nowEpoch >= expiryEpoch;
|
|
400
401
|
}
|
|
401
402
|
|
|
403
|
+
export function isSessionCacheExpired(metadata, nowIso = new Date().toISOString()) {
|
|
404
|
+
return isExpired(metadata, nowIso);
|
|
405
|
+
}
|
|
406
|
+
|
|
402
407
|
function buildSessionPayload(metadata, paths, nowIso = new Date().toISOString()) {
|
|
403
408
|
return {
|
|
404
409
|
sessionId: metadata.sessionId,
|
|
@@ -526,6 +531,53 @@ export async function updateSessionTitle(
|
|
|
526
531
|
return buildSessionPayload(saved, loaded.paths, nowIso);
|
|
527
532
|
}
|
|
528
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
|
+
|
|
529
581
|
export async function recordSessionRemoteTitleSync(
|
|
530
582
|
sessionId,
|
|
531
583
|
{
|