sentinelayer-cli 0.9.4 → 0.9.6
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 +333 -0
- package/src/session/checkpoints.js +294 -0
- package/src/session/recap.js +70 -6
package/package.json
CHANGED
package/src/commands/session.js
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
import { hydrateSessionFromRemote } from "../session/remote-hydrate.js";
|
|
69
69
|
import { mergeLiveSources } from "../session/live-source.js";
|
|
70
70
|
import { listenSessionEvents } from "../session/listener.js";
|
|
71
|
+
import { buildSessionRecap } from "../session/recap.js";
|
|
71
72
|
import { deriveSessionTitle } from "../session/senti-naming.js";
|
|
72
73
|
import { pushSessionTitleToApi } from "../session/title-sync.js";
|
|
73
74
|
import {
|
|
@@ -76,6 +77,11 @@ import {
|
|
|
76
77
|
getTemplateRegistry,
|
|
77
78
|
resolveSessionTemplate,
|
|
78
79
|
} from "../session/templates.js";
|
|
80
|
+
import {
|
|
81
|
+
createSessionCheckpoint,
|
|
82
|
+
generateSessionCheckpoint,
|
|
83
|
+
listSessionCheckpoints,
|
|
84
|
+
} from "../session/checkpoints.js";
|
|
79
85
|
import { authLoginHint } from "../ui/command-hints.js";
|
|
80
86
|
import { parseCsvTokens } from "./ai/shared.js";
|
|
81
87
|
|
|
@@ -560,6 +566,98 @@ function formatEventLine(event = {}) {
|
|
|
560
566
|
return `${ts} ${agentId} ${type}`;
|
|
561
567
|
}
|
|
562
568
|
|
|
569
|
+
function checkpointSequenceRange(checkpoint = {}) {
|
|
570
|
+
const start = Number(checkpoint.startSequence || 0);
|
|
571
|
+
const end = Number(checkpoint.endSequence || 0);
|
|
572
|
+
if (Number.isFinite(start) && start > 0 && Number.isFinite(end) && end > 0) {
|
|
573
|
+
return `#${Math.floor(start)}-${Math.floor(end)}`;
|
|
574
|
+
}
|
|
575
|
+
if (Number.isFinite(start) && start > 0) {
|
|
576
|
+
return `#${Math.floor(start)}`;
|
|
577
|
+
}
|
|
578
|
+
return "anchor pending";
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function formatCheckpointLine(checkpoint = {}) {
|
|
582
|
+
const id = normalizeString(checkpoint.checkpointId) || "checkpoint";
|
|
583
|
+
const kind = normalizeString(checkpoint.kind) || "summary";
|
|
584
|
+
const title = normalizeString(checkpoint.title) || "Untitled checkpoint";
|
|
585
|
+
const byline = normalizeString(checkpoint.createdByAgentId || checkpoint.createdBy);
|
|
586
|
+
const by = byline ? ` by ${byline}` : "";
|
|
587
|
+
return `${checkpointSequenceRange(checkpoint)} ${id} [${kind}] ${title}${by}`;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function readCheckpointSummaryOption(options = {}, { targetPath } = {}) {
|
|
591
|
+
const inlineSummary = normalizeString(options.summary);
|
|
592
|
+
const summaryFile = normalizeString(options.summaryFile);
|
|
593
|
+
if (inlineSummary && summaryFile) {
|
|
594
|
+
throw new Error("Use either --summary or --summary-file, not both.");
|
|
595
|
+
}
|
|
596
|
+
if (summaryFile) {
|
|
597
|
+
const resolved = path.resolve(targetPath || process.cwd(), summaryFile);
|
|
598
|
+
return fsp.readFile(resolved, "utf-8");
|
|
599
|
+
}
|
|
600
|
+
return inlineSummary;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
async function hydrateAfterCheckpointMutation(sessionId, { targetPath } = {}) {
|
|
604
|
+
return hydrateSessionFromRemote({
|
|
605
|
+
sessionId,
|
|
606
|
+
targetPath,
|
|
607
|
+
probeOpenCircuit: false,
|
|
608
|
+
eventPageLimit: 200,
|
|
609
|
+
maxEventPages: 5,
|
|
610
|
+
}).catch((error) => ({
|
|
611
|
+
ok: false,
|
|
612
|
+
reason: error instanceof Error ? error.message : "hydrate_failed",
|
|
613
|
+
}));
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function appendMissingRemoteEvents(sessionId, remoteEvents = [], { targetPath } = {}) {
|
|
617
|
+
const events = Array.isArray(remoteEvents) ? remoteEvents : [];
|
|
618
|
+
if (events.length === 0) {
|
|
619
|
+
return {
|
|
620
|
+
appended: 0,
|
|
621
|
+
skipped: 0,
|
|
622
|
+
failed: 0,
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
const knownKeys = new Set();
|
|
626
|
+
const localEvents = await readStream(sessionId, {
|
|
627
|
+
targetPath,
|
|
628
|
+
tail: 0,
|
|
629
|
+
});
|
|
630
|
+
for (const event of localEvents) {
|
|
631
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
let appended = 0;
|
|
635
|
+
let skipped = 0;
|
|
636
|
+
let failed = 0;
|
|
637
|
+
for (const event of events) {
|
|
638
|
+
if (sessionEventHasKnownIdentity(event, knownKeys)) {
|
|
639
|
+
skipped += 1;
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
try {
|
|
643
|
+
const persisted = await appendToStream(sessionId, event, {
|
|
644
|
+
targetPath,
|
|
645
|
+
syncRemote: false,
|
|
646
|
+
});
|
|
647
|
+
addSessionEventIdentityKeys(knownKeys, persisted);
|
|
648
|
+
appended += 1;
|
|
649
|
+
} catch {
|
|
650
|
+
addSessionEventIdentityKeys(knownKeys, event);
|
|
651
|
+
failed += 1;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
appended,
|
|
656
|
+
skipped,
|
|
657
|
+
failed,
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
|
|
563
661
|
function formatTemplateLaunchLine(slot = {}) {
|
|
564
662
|
const terminal = Number(slot.terminal || 0);
|
|
565
663
|
const role = normalizeString(slot.role) || "agent";
|
|
@@ -1479,6 +1577,92 @@ export function registerSessionCommand(program) {
|
|
|
1479
1577
|
}
|
|
1480
1578
|
});
|
|
1481
1579
|
|
|
1580
|
+
const recap = session
|
|
1581
|
+
.command("recap")
|
|
1582
|
+
.description("Build deterministic Senti session recaps");
|
|
1583
|
+
|
|
1584
|
+
recap
|
|
1585
|
+
.command("now [sessionId]")
|
|
1586
|
+
.description("Summarize current session activity, peers, findings, locks, and task ownership")
|
|
1587
|
+
.option("--session <id>", "Session id to recap")
|
|
1588
|
+
.option(
|
|
1589
|
+
"--remote",
|
|
1590
|
+
"Hydrate the latest durable API events before building the recap",
|
|
1591
|
+
)
|
|
1592
|
+
.option(
|
|
1593
|
+
"--agent <id>",
|
|
1594
|
+
"Agent id requesting the recap; self-authored events are omitted from recent snippets",
|
|
1595
|
+
process.env.SENTINELAYER_AGENT_ID || "",
|
|
1596
|
+
)
|
|
1597
|
+
.option("--max-events <n>", "Maximum recent local events to inspect (default 100)", "100")
|
|
1598
|
+
.option("--path <path>", "Workspace path for the session", ".")
|
|
1599
|
+
.option("--json", "Emit machine-readable output")
|
|
1600
|
+
.action(async (sessionId, options, command) => {
|
|
1601
|
+
const normalizedSessionId = normalizeString(sessionId) || resolveSessionIdOption(options);
|
|
1602
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1603
|
+
const agentId = normalizeAgentId(options.agent, "");
|
|
1604
|
+
const maxEvents = parsePositiveInteger(options.maxEvents, "max-events", 100);
|
|
1605
|
+
let hydration = null;
|
|
1606
|
+
let remoteTail = null;
|
|
1607
|
+
let remoteAppend = null;
|
|
1608
|
+
if (options.remote) {
|
|
1609
|
+
hydration = await hydrateSessionFromRemote({
|
|
1610
|
+
sessionId: normalizedSessionId,
|
|
1611
|
+
targetPath,
|
|
1612
|
+
});
|
|
1613
|
+
remoteTail = await pollSessionEventsBefore(normalizedSessionId, {
|
|
1614
|
+
targetPath,
|
|
1615
|
+
limit: maxEvents,
|
|
1616
|
+
timeoutMs: 15_000,
|
|
1617
|
+
});
|
|
1618
|
+
if (remoteTail?.ok && Array.isArray(remoteTail.events) && remoteTail.events.length > 0) {
|
|
1619
|
+
remoteAppend = await appendMissingRemoteEvents(normalizedSessionId, remoteTail.events, {
|
|
1620
|
+
targetPath,
|
|
1621
|
+
});
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
const current = await buildSessionRecap(normalizedSessionId, {
|
|
1625
|
+
forAgentId: agentId,
|
|
1626
|
+
maxEvents,
|
|
1627
|
+
targetPath,
|
|
1628
|
+
});
|
|
1629
|
+
const payload = {
|
|
1630
|
+
command: "session recap now",
|
|
1631
|
+
targetPath,
|
|
1632
|
+
sessionId: normalizedSessionId,
|
|
1633
|
+
agentId: current.forAgentId,
|
|
1634
|
+
maxEvents,
|
|
1635
|
+
generatedAt: current.generatedAt,
|
|
1636
|
+
ephemeral: current.ephemeral,
|
|
1637
|
+
style: current.style,
|
|
1638
|
+
recap: current.text,
|
|
1639
|
+
summary: current.summary,
|
|
1640
|
+
remote: options.remote
|
|
1641
|
+
? {
|
|
1642
|
+
hydration,
|
|
1643
|
+
tailProbe: remoteTail
|
|
1644
|
+
? {
|
|
1645
|
+
ok: Boolean(remoteTail.ok),
|
|
1646
|
+
reason: remoteTail.reason || "",
|
|
1647
|
+
count: Array.isArray(remoteTail.events) ? remoteTail.events.length : 0,
|
|
1648
|
+
cursor: remoteTail.cursor || null,
|
|
1649
|
+
}
|
|
1650
|
+
: null,
|
|
1651
|
+
appendedTail: remoteAppend,
|
|
1652
|
+
}
|
|
1653
|
+
: null,
|
|
1654
|
+
};
|
|
1655
|
+
if (shouldEmitJson(options, command)) {
|
|
1656
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1657
|
+
return;
|
|
1658
|
+
}
|
|
1659
|
+
console.log(pc.bold(`Recap for session ${normalizedSessionId}`));
|
|
1660
|
+
if (payload.agentId) {
|
|
1661
|
+
console.log(pc.gray(`for agent=${payload.agentId}`));
|
|
1662
|
+
}
|
|
1663
|
+
console.log(current.text);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1482
1666
|
session
|
|
1483
1667
|
.command("read <sessionId>")
|
|
1484
1668
|
.description("Read recent session messages")
|
|
@@ -1752,6 +1936,155 @@ export function registerSessionCommand(program) {
|
|
|
1752
1936
|
}
|
|
1753
1937
|
});
|
|
1754
1938
|
|
|
1939
|
+
const checkpoint = session
|
|
1940
|
+
.command("checkpoint")
|
|
1941
|
+
.description("List, create, and generate durable session checkpoints");
|
|
1942
|
+
|
|
1943
|
+
checkpoint
|
|
1944
|
+
.command("list <sessionId>")
|
|
1945
|
+
.description("List durable checkpoints for a remote session")
|
|
1946
|
+
.option("--limit <n>", "Maximum checkpoints to return (default 100, max 200)", "100")
|
|
1947
|
+
.option("--path <path>", "Workspace path for auth/session context", ".")
|
|
1948
|
+
.option("--json", "Emit machine-readable output")
|
|
1949
|
+
.action(async (sessionId, options, command) => {
|
|
1950
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1951
|
+
if (!normalizedSessionId) {
|
|
1952
|
+
throw new Error("session id is required.");
|
|
1953
|
+
}
|
|
1954
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1955
|
+
const result = await listSessionCheckpoints(normalizedSessionId, {
|
|
1956
|
+
targetPath,
|
|
1957
|
+
limit: options.limit,
|
|
1958
|
+
});
|
|
1959
|
+
const payload = {
|
|
1960
|
+
command: "session checkpoint list",
|
|
1961
|
+
targetPath,
|
|
1962
|
+
...result,
|
|
1963
|
+
};
|
|
1964
|
+
if (shouldEmitJson(options, command)) {
|
|
1965
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
1966
|
+
return;
|
|
1967
|
+
}
|
|
1968
|
+
if (result.checkpoints.length === 0) {
|
|
1969
|
+
console.log(pc.gray(`No checkpoints for session ${normalizedSessionId}.`));
|
|
1970
|
+
return;
|
|
1971
|
+
}
|
|
1972
|
+
for (const item of result.checkpoints) {
|
|
1973
|
+
console.log(formatCheckpointLine(item));
|
|
1974
|
+
}
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
checkpoint
|
|
1978
|
+
.command("create <sessionId>")
|
|
1979
|
+
.description("Create a durable checkpoint anchored to a canonical sequence range")
|
|
1980
|
+
.requiredOption("--start-sequence <n>", "First canonical event sequence included in the checkpoint")
|
|
1981
|
+
.requiredOption("--end-sequence <n>", "Last canonical event sequence included in the checkpoint")
|
|
1982
|
+
.requiredOption("--title <title>", "Short checkpoint title")
|
|
1983
|
+
.option("--summary <text>", "Checkpoint summary text")
|
|
1984
|
+
.option("--summary-file <file>", "Read checkpoint summary text from a file")
|
|
1985
|
+
.option("--kind <kind>", "Checkpoint kind (summary, handoff, milestone, billing)", "summary")
|
|
1986
|
+
.option("--checkpoint-id <id>", "Explicit checkpoint id; defaults to a stable hash of range/body")
|
|
1987
|
+
.option("--agent <id>", "Optional agent id recorded as checkpoint creator")
|
|
1988
|
+
.option("--token-start <n>", "Optional token-range start")
|
|
1989
|
+
.option("--token-end <n>", "Optional token-range end")
|
|
1990
|
+
.option("--path <path>", "Workspace path for auth/session context", ".")
|
|
1991
|
+
.option("--json", "Emit machine-readable output")
|
|
1992
|
+
.action(async (sessionId, options, command) => {
|
|
1993
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1994
|
+
if (!normalizedSessionId) {
|
|
1995
|
+
throw new Error("session id is required.");
|
|
1996
|
+
}
|
|
1997
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
1998
|
+
const agentId = normalizeString(options.agent)
|
|
1999
|
+
? await defaultAgentId(options.agent, targetPath)
|
|
2000
|
+
: "";
|
|
2001
|
+
const summary = await readCheckpointSummaryOption(options, { targetPath });
|
|
2002
|
+
const result = await createSessionCheckpoint(normalizedSessionId, {
|
|
2003
|
+
targetPath,
|
|
2004
|
+
checkpointId: options.checkpointId,
|
|
2005
|
+
startSequence: options.startSequence,
|
|
2006
|
+
endSequence: options.endSequence,
|
|
2007
|
+
kind: options.kind,
|
|
2008
|
+
title: options.title,
|
|
2009
|
+
summary,
|
|
2010
|
+
createdByAgentId: agentId,
|
|
2011
|
+
tokenStart: options.tokenStart,
|
|
2012
|
+
tokenEnd: options.tokenEnd,
|
|
2013
|
+
});
|
|
2014
|
+
const hydration = await hydrateAfterCheckpointMutation(normalizedSessionId, { targetPath });
|
|
2015
|
+
const payload = {
|
|
2016
|
+
command: "session checkpoint create",
|
|
2017
|
+
targetPath,
|
|
2018
|
+
...result,
|
|
2019
|
+
hydration,
|
|
2020
|
+
};
|
|
2021
|
+
if (shouldEmitJson(options, command)) {
|
|
2022
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2023
|
+
return;
|
|
2024
|
+
}
|
|
2025
|
+
const duplicate = result.duplicate ? "duplicate " : "";
|
|
2026
|
+
console.log(pc.bold(`${duplicate}checkpoint created`));
|
|
2027
|
+
if (result.checkpoint) {
|
|
2028
|
+
console.log(formatCheckpointLine(result.checkpoint));
|
|
2029
|
+
}
|
|
2030
|
+
if (!hydration.ok) {
|
|
2031
|
+
console.log(pc.gray(`Local hydrate skipped: ${hydration.reason || "unknown"}`));
|
|
2032
|
+
}
|
|
2033
|
+
});
|
|
2034
|
+
|
|
2035
|
+
checkpoint
|
|
2036
|
+
.command("generate <sessionId>")
|
|
2037
|
+
.description("Generate a checkpoint from the next uncheckpointed durable event window")
|
|
2038
|
+
.option("--min-events <n>", "Minimum source events required before creating (default 20)", "20")
|
|
2039
|
+
.option("--max-events <n>", "Maximum source events to summarize (default 80, max 200)", "80")
|
|
2040
|
+
.option("--operation-id <key>", "Explicit retry key for this generate invocation")
|
|
2041
|
+
.option("--agent <id>", "Optional agent id recorded as checkpoint creator")
|
|
2042
|
+
.option("--path <path>", "Workspace path for auth/session context", ".")
|
|
2043
|
+
.option("--json", "Emit machine-readable output")
|
|
2044
|
+
.action(async (sessionId, options, command) => {
|
|
2045
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
2046
|
+
if (!normalizedSessionId) {
|
|
2047
|
+
throw new Error("session id is required.");
|
|
2048
|
+
}
|
|
2049
|
+
const targetPath = path.resolve(process.cwd(), String(options.path || "."));
|
|
2050
|
+
const agentId = normalizeString(options.agent)
|
|
2051
|
+
? await defaultAgentId(options.agent, targetPath)
|
|
2052
|
+
: "";
|
|
2053
|
+
const result = await generateSessionCheckpoint(normalizedSessionId, {
|
|
2054
|
+
targetPath,
|
|
2055
|
+
minEvents: options.minEvents,
|
|
2056
|
+
maxEvents: options.maxEvents,
|
|
2057
|
+
idempotencyKey: options.operationId,
|
|
2058
|
+
createdByAgentId: agentId,
|
|
2059
|
+
});
|
|
2060
|
+
const hydration = result.checkpoint
|
|
2061
|
+
? await hydrateAfterCheckpointMutation(normalizedSessionId, { targetPath })
|
|
2062
|
+
: null;
|
|
2063
|
+
const payload = {
|
|
2064
|
+
command: "session checkpoint generate",
|
|
2065
|
+
targetPath,
|
|
2066
|
+
...result,
|
|
2067
|
+
hydration: hydration || undefined,
|
|
2068
|
+
};
|
|
2069
|
+
if (shouldEmitJson(options, command)) {
|
|
2070
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
2071
|
+
return;
|
|
2072
|
+
}
|
|
2073
|
+
if (result.checkpoint) {
|
|
2074
|
+
console.log(pc.bold(result.duplicate ? "checkpoint already covered" : "checkpoint generated"));
|
|
2075
|
+
console.log(formatCheckpointLine(result.checkpoint));
|
|
2076
|
+
if (hydration && !hydration.ok) {
|
|
2077
|
+
console.log(pc.gray(`Local hydrate skipped: ${hydration.reason || "unknown"}`));
|
|
2078
|
+
}
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
console.log(
|
|
2082
|
+
pc.gray(
|
|
2083
|
+
`No checkpoint created: ${normalizeString(result.reason) || "not_needed"} (${Number(result.eventCount || 0)} events, min ${Number(result.minEvents || 0)}).`,
|
|
2084
|
+
),
|
|
2085
|
+
);
|
|
2086
|
+
});
|
|
2087
|
+
|
|
1755
2088
|
session
|
|
1756
2089
|
.command("status <sessionId>")
|
|
1757
2090
|
.description("Show session status, agents, and health")
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
|
|
4
|
+
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
5
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
6
|
+
|
|
7
|
+
const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
|
|
8
|
+
const DEFAULT_CHECKPOINT_LIMIT = 100;
|
|
9
|
+
const MAX_CHECKPOINT_LIMIT = 200;
|
|
10
|
+
const DEFAULT_MIN_EVENTS = 20;
|
|
11
|
+
const DEFAULT_MAX_EVENTS = 80;
|
|
12
|
+
|
|
13
|
+
function normalizeString(value) {
|
|
14
|
+
return String(value || "").trim();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeApiUrl(value) {
|
|
18
|
+
return (normalizeString(value) || DEFAULT_API_BASE_URL).replace(/\/+$/g, "");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function parsePositiveInteger(value, field, fallbackValue = null) {
|
|
22
|
+
if (value === undefined || value === null || normalizeString(value) === "") {
|
|
23
|
+
if (fallbackValue !== null) return fallbackValue;
|
|
24
|
+
throw new Error(`${field} is required.`);
|
|
25
|
+
}
|
|
26
|
+
const parsed = Number(value);
|
|
27
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
28
|
+
throw new Error(`${field} must be a positive integer.`);
|
|
29
|
+
}
|
|
30
|
+
return Math.floor(parsed);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeLimit(value) {
|
|
34
|
+
const parsed = parsePositiveInteger(value, "limit", DEFAULT_CHECKPOINT_LIMIT);
|
|
35
|
+
return Math.max(1, Math.min(MAX_CHECKPOINT_LIMIT, parsed));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function stableHash(value) {
|
|
39
|
+
return crypto
|
|
40
|
+
.createHash("sha256")
|
|
41
|
+
.update(JSON.stringify(stableJson(value)))
|
|
42
|
+
.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stableJson(value) {
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
return value.map((item) => stableJson(item));
|
|
48
|
+
}
|
|
49
|
+
if (!value || typeof value !== "object") {
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
const out = {};
|
|
53
|
+
for (const key of Object.keys(value).sort()) {
|
|
54
|
+
const next = stableJson(value[key]);
|
|
55
|
+
if (next !== undefined && next !== null && next !== "") {
|
|
56
|
+
out[key] = next;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function buildCheckpointFingerprint(payload) {
|
|
63
|
+
return stableHash(stableJson(payload));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function buildStableCheckpointId(sessionId, payload) {
|
|
67
|
+
const fingerprint = buildCheckpointFingerprint({
|
|
68
|
+
sessionId: normalizeString(sessionId),
|
|
69
|
+
startSequence: payload.startSequence,
|
|
70
|
+
endSequence: payload.endSequence,
|
|
71
|
+
kind: payload.kind,
|
|
72
|
+
title: payload.title,
|
|
73
|
+
summary: payload.summary,
|
|
74
|
+
createdByAgentId: payload.createdByAgentId,
|
|
75
|
+
tokenRange: payload.tokenRange || null,
|
|
76
|
+
});
|
|
77
|
+
return `cp_cli_${fingerprint.slice(0, 24)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function buildStableIdempotencyKey(sessionId, operation, payload) {
|
|
81
|
+
const fingerprint = buildCheckpointFingerprint({
|
|
82
|
+
sessionId: normalizeString(sessionId),
|
|
83
|
+
operation,
|
|
84
|
+
payload,
|
|
85
|
+
});
|
|
86
|
+
return `sl_cli_session_checkpoint_${fingerprint}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildInvocationIdempotencyKey(operation) {
|
|
90
|
+
let suffix;
|
|
91
|
+
try {
|
|
92
|
+
suffix = crypto.randomUUID();
|
|
93
|
+
} catch {
|
|
94
|
+
suffix = crypto.randomBytes(16).toString("hex");
|
|
95
|
+
}
|
|
96
|
+
return `sl_cli_session_checkpoint_${normalizeString(operation) || "mutation"}_${suffix}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeTokenRange({ tokenStart, tokenEnd } = {}) {
|
|
100
|
+
const hasStart = tokenStart !== undefined && normalizeString(tokenStart) !== "";
|
|
101
|
+
const hasEnd = tokenEnd !== undefined && normalizeString(tokenEnd) !== "";
|
|
102
|
+
if (!hasStart && !hasEnd) return null;
|
|
103
|
+
const start = hasStart ? parsePositiveInteger(tokenStart, "token-start") : null;
|
|
104
|
+
const end = hasEnd ? parsePositiveInteger(tokenEnd, "token-end") : null;
|
|
105
|
+
if (start !== null && end !== null && start > end) {
|
|
106
|
+
throw new Error("token-start must be less than or equal to token-end.");
|
|
107
|
+
}
|
|
108
|
+
return { start, end };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function buildManualCheckpointPayload(sessionId, {
|
|
112
|
+
checkpointId = "",
|
|
113
|
+
startSequence,
|
|
114
|
+
endSequence,
|
|
115
|
+
kind = "summary",
|
|
116
|
+
title,
|
|
117
|
+
summary,
|
|
118
|
+
createdByAgentId = "",
|
|
119
|
+
tokenStart,
|
|
120
|
+
tokenEnd,
|
|
121
|
+
} = {}) {
|
|
122
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
123
|
+
if (!normalizedSessionId) {
|
|
124
|
+
throw new Error("session id is required.");
|
|
125
|
+
}
|
|
126
|
+
const normalizedStart = parsePositiveInteger(startSequence, "start-sequence");
|
|
127
|
+
const normalizedEnd = parsePositiveInteger(endSequence, "end-sequence");
|
|
128
|
+
if (normalizedStart > normalizedEnd) {
|
|
129
|
+
throw new Error("start-sequence must be less than or equal to end-sequence.");
|
|
130
|
+
}
|
|
131
|
+
const normalizedTitle = normalizeString(title);
|
|
132
|
+
if (!normalizedTitle) {
|
|
133
|
+
throw new Error("title is required.");
|
|
134
|
+
}
|
|
135
|
+
const normalizedSummary = normalizeString(summary);
|
|
136
|
+
if (!normalizedSummary) {
|
|
137
|
+
throw new Error("summary is required.");
|
|
138
|
+
}
|
|
139
|
+
const normalizedKind = normalizeString(kind) || "summary";
|
|
140
|
+
const normalizedCreatedBy = normalizeString(createdByAgentId);
|
|
141
|
+
const tokenRange = normalizeTokenRange({ tokenStart, tokenEnd });
|
|
142
|
+
const body = {
|
|
143
|
+
startSequence: normalizedStart,
|
|
144
|
+
endSequence: normalizedEnd,
|
|
145
|
+
kind: normalizedKind,
|
|
146
|
+
title: normalizedTitle,
|
|
147
|
+
summary: normalizedSummary,
|
|
148
|
+
};
|
|
149
|
+
if (normalizedCreatedBy) {
|
|
150
|
+
body.createdByAgentId = normalizedCreatedBy;
|
|
151
|
+
}
|
|
152
|
+
if (tokenRange) {
|
|
153
|
+
body.tokenRange = tokenRange;
|
|
154
|
+
}
|
|
155
|
+
body.checkpointId = normalizeString(checkpointId) || buildStableCheckpointId(normalizedSessionId, body);
|
|
156
|
+
return {
|
|
157
|
+
body,
|
|
158
|
+
idempotencyKey: buildStableIdempotencyKey(normalizedSessionId, "create", body),
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function buildGenerateCheckpointPayload(sessionId, {
|
|
163
|
+
minEvents = DEFAULT_MIN_EVENTS,
|
|
164
|
+
maxEvents = DEFAULT_MAX_EVENTS,
|
|
165
|
+
createdByAgentId = "",
|
|
166
|
+
idempotencyKey = "",
|
|
167
|
+
} = {}) {
|
|
168
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
169
|
+
if (!normalizedSessionId) {
|
|
170
|
+
throw new Error("session id is required.");
|
|
171
|
+
}
|
|
172
|
+
const normalizedMin = parsePositiveInteger(minEvents, "min-events", DEFAULT_MIN_EVENTS);
|
|
173
|
+
const normalizedMax = parsePositiveInteger(maxEvents, "max-events", DEFAULT_MAX_EVENTS);
|
|
174
|
+
if (normalizedMin > 200) {
|
|
175
|
+
throw new Error("min-events must be less than or equal to 200.");
|
|
176
|
+
}
|
|
177
|
+
if (normalizedMax > 200) {
|
|
178
|
+
throw new Error("max-events must be less than or equal to 200.");
|
|
179
|
+
}
|
|
180
|
+
if (normalizedMax < normalizedMin) {
|
|
181
|
+
throw new Error("max-events must be greater than or equal to min-events.");
|
|
182
|
+
}
|
|
183
|
+
const body = {
|
|
184
|
+
minEvents: normalizedMin,
|
|
185
|
+
maxEvents: normalizedMax,
|
|
186
|
+
};
|
|
187
|
+
const normalizedCreatedBy = normalizeString(createdByAgentId);
|
|
188
|
+
if (normalizedCreatedBy) {
|
|
189
|
+
body.createdByAgentId = normalizedCreatedBy;
|
|
190
|
+
}
|
|
191
|
+
return {
|
|
192
|
+
body,
|
|
193
|
+
idempotencyKey:
|
|
194
|
+
normalizeString(idempotencyKey) || buildInvocationIdempotencyKey("generate"),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function resolveCheckpointApi({
|
|
199
|
+
targetPath = process.cwd(),
|
|
200
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
201
|
+
} = {}) {
|
|
202
|
+
const auth = await resolveAuthSession({
|
|
203
|
+
cwd: targetPath,
|
|
204
|
+
env: process.env,
|
|
205
|
+
autoRotate: false,
|
|
206
|
+
});
|
|
207
|
+
if (!auth || !auth.token) {
|
|
208
|
+
throw new Error("Sentinelayer auth is required. Run `sl auth login` first.");
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
apiUrl: normalizeApiUrl(auth.apiUrl),
|
|
212
|
+
headers: { Authorization: `Bearer ${auth.token}` },
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function listSessionCheckpoints(sessionId, {
|
|
217
|
+
targetPath = process.cwd(),
|
|
218
|
+
limit = DEFAULT_CHECKPOINT_LIMIT,
|
|
219
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
220
|
+
request = requestJson,
|
|
221
|
+
} = {}) {
|
|
222
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
223
|
+
if (!normalizedSessionId) {
|
|
224
|
+
throw new Error("session id is required.");
|
|
225
|
+
}
|
|
226
|
+
const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
|
|
227
|
+
const params = new URLSearchParams({ limit: String(normalizeLimit(limit)) });
|
|
228
|
+
const response = await request(
|
|
229
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints?${params.toString()}`,
|
|
230
|
+
{ method: "GET", headers },
|
|
231
|
+
);
|
|
232
|
+
const checkpoints = Array.isArray(response?.checkpoints) ? response.checkpoints : [];
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
sessionId: normalizedSessionId,
|
|
236
|
+
apiUrl,
|
|
237
|
+
checkpoints,
|
|
238
|
+
count: Number(response?.count ?? checkpoints.length) || checkpoints.length,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function createSessionCheckpoint(sessionId, options = {}) {
|
|
243
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
244
|
+
const {
|
|
245
|
+
targetPath = process.cwd(),
|
|
246
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
247
|
+
requestMutation = requestJsonMutation,
|
|
248
|
+
} = options;
|
|
249
|
+
const { body, idempotencyKey } = buildManualCheckpointPayload(normalizedSessionId, options);
|
|
250
|
+
const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
|
|
251
|
+
const response = await requestMutation(
|
|
252
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints`,
|
|
253
|
+
{
|
|
254
|
+
operationName: "session-checkpoint-create",
|
|
255
|
+
headers,
|
|
256
|
+
body,
|
|
257
|
+
idempotencyKey,
|
|
258
|
+
},
|
|
259
|
+
);
|
|
260
|
+
return {
|
|
261
|
+
...response,
|
|
262
|
+
sessionId: normalizedSessionId,
|
|
263
|
+
apiUrl,
|
|
264
|
+
checkpoint: response?.checkpoint || null,
|
|
265
|
+
idempotencyKey,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function generateSessionCheckpoint(sessionId, options = {}) {
|
|
270
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
271
|
+
const {
|
|
272
|
+
targetPath = process.cwd(),
|
|
273
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
274
|
+
requestMutation = requestJsonMutation,
|
|
275
|
+
} = options;
|
|
276
|
+
const { body, idempotencyKey } = buildGenerateCheckpointPayload(normalizedSessionId, options);
|
|
277
|
+
const { apiUrl, headers } = await resolveCheckpointApi({ targetPath, resolveAuthSession });
|
|
278
|
+
const response = await requestMutation(
|
|
279
|
+
`${apiUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/checkpoints/generate`,
|
|
280
|
+
{
|
|
281
|
+
operationName: "session-checkpoint-generate",
|
|
282
|
+
headers,
|
|
283
|
+
body,
|
|
284
|
+
idempotencyKey,
|
|
285
|
+
},
|
|
286
|
+
);
|
|
287
|
+
return {
|
|
288
|
+
...response,
|
|
289
|
+
sessionId: normalizedSessionId,
|
|
290
|
+
apiUrl,
|
|
291
|
+
checkpoint: response?.checkpoint || null,
|
|
292
|
+
idempotencyKey,
|
|
293
|
+
};
|
|
294
|
+
}
|
package/src/session/recap.js
CHANGED
|
@@ -3,8 +3,10 @@ import path from "node:path";
|
|
|
3
3
|
import process from "node:process";
|
|
4
4
|
|
|
5
5
|
import { createAgentEvent } from "../events/schema.js";
|
|
6
|
+
import { dedupeSessionEvents } from "./event-identity.js";
|
|
6
7
|
import { resolveSessionPaths } from "./paths.js";
|
|
7
8
|
import { appendToStream, readStream } from "./stream.js";
|
|
9
|
+
import { getSession } from "./store.js";
|
|
8
10
|
|
|
9
11
|
const SENTI_AGENT_ID = "senti";
|
|
10
12
|
const SENTI_MODEL = "gpt-5.4-mini";
|
|
@@ -279,11 +281,12 @@ async function readTaskLedgerSummary(
|
|
|
279
281
|
}
|
|
280
282
|
}
|
|
281
283
|
|
|
282
|
-
function
|
|
283
|
-
|
|
284
|
+
function elapsedMinutesBetween(startIso, nowIso = new Date().toISOString()) {
|
|
285
|
+
const normalizedStartIso = normalizeString(startIso);
|
|
286
|
+
if (!normalizedStartIso) {
|
|
284
287
|
return 0;
|
|
285
288
|
}
|
|
286
|
-
const firstEpoch = toEpoch(
|
|
289
|
+
const firstEpoch = toEpoch(normalizedStartIso, nowIso);
|
|
287
290
|
const nowEpoch = toEpoch(nowIso, nowIso);
|
|
288
291
|
if (!Number.isFinite(firstEpoch) || !Number.isFinite(nowEpoch) || nowEpoch <= firstEpoch) {
|
|
289
292
|
return 0;
|
|
@@ -291,6 +294,51 @@ function buildElapsedMinutes(events = [], nowIso = new Date().toISOString()) {
|
|
|
291
294
|
return Math.max(0, Math.floor((nowEpoch - firstEpoch) / 60_000));
|
|
292
295
|
}
|
|
293
296
|
|
|
297
|
+
function earliestIso(values = [], fallbackIso = new Date().toISOString()) {
|
|
298
|
+
const validEpochs = values
|
|
299
|
+
.map((value) => normalizeString(value))
|
|
300
|
+
.filter(Boolean)
|
|
301
|
+
.map((value) => Date.parse(value))
|
|
302
|
+
.filter((value) => Number.isFinite(value));
|
|
303
|
+
if (validEpochs.length === 0) {
|
|
304
|
+
return "";
|
|
305
|
+
}
|
|
306
|
+
validEpochs.sort((left, right) => left - right);
|
|
307
|
+
return normalizeIsoTimestamp(new Date(validEpochs[0]).toISOString(), fallbackIso);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function buildElapsedMinutes(events = [], nowIso = new Date().toISOString(), { startedAt = "" } = {}) {
|
|
311
|
+
const eventStart = Array.isArray(events) && events.length > 0 ? events[0]?.ts || events[0]?.timestamp : "";
|
|
312
|
+
const startIso = earliestIso([startedAt, eventStart], nowIso);
|
|
313
|
+
return elapsedMinutesBetween(startIso, nowIso);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function eventSequenceNumber(event = {}) {
|
|
317
|
+
for (const value of [event.sequenceId, event.sequence, event.seq, event.payload?.sequenceId]) {
|
|
318
|
+
const normalized = Number(value);
|
|
319
|
+
if (Number.isFinite(normalized)) {
|
|
320
|
+
return normalized;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function sortEventsByConversationTime(events = [], fallbackIso = new Date().toISOString()) {
|
|
327
|
+
return [...(Array.isArray(events) ? events : [])].sort((left, right) => {
|
|
328
|
+
const leftEpoch = toEpoch(left?.ts || left?.timestamp, fallbackIso);
|
|
329
|
+
const rightEpoch = toEpoch(right?.ts || right?.timestamp, fallbackIso);
|
|
330
|
+
if (leftEpoch !== rightEpoch) {
|
|
331
|
+
return leftEpoch - rightEpoch;
|
|
332
|
+
}
|
|
333
|
+
const leftSequence = eventSequenceNumber(left);
|
|
334
|
+
const rightSequence = eventSequenceNumber(right);
|
|
335
|
+
if (leftSequence !== rightSequence) {
|
|
336
|
+
return leftSequence - rightSequence;
|
|
337
|
+
}
|
|
338
|
+
return normalizeString(left?.cursor).localeCompare(normalizeString(right?.cursor));
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
294
342
|
function buildRecapKey(sessionId, targetPath) {
|
|
295
343
|
return `${path.resolve(String(targetPath || "."))}::${normalizeString(sessionId)}`;
|
|
296
344
|
}
|
|
@@ -413,10 +461,19 @@ export async function buildSessionRecap(
|
|
|
413
461
|
const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_RECAP_MAX_EVENTS);
|
|
414
462
|
const normalizedForAgentId = normalizeString(forAgentId);
|
|
415
463
|
|
|
416
|
-
const
|
|
464
|
+
const allEvents = await readStream(normalizedSessionId, {
|
|
417
465
|
targetPath: normalizedTargetPath,
|
|
418
|
-
tail:
|
|
466
|
+
tail: 0,
|
|
419
467
|
});
|
|
468
|
+
let sessionMetadata = null;
|
|
469
|
+
try {
|
|
470
|
+
sessionMetadata = await getSession(normalizedSessionId, { targetPath: normalizedTargetPath });
|
|
471
|
+
} catch {
|
|
472
|
+
sessionMetadata = null;
|
|
473
|
+
}
|
|
474
|
+
const events = sortEventsByConversationTime(dedupeSessionEvents(allEvents), normalizedNow).slice(
|
|
475
|
+
-normalizedMaxEvents,
|
|
476
|
+
);
|
|
420
477
|
const visibleEvents = (Array.isArray(events) ? events : []).filter((event) => {
|
|
421
478
|
const agentId = normalizeString(event.agent?.id || event.agentId);
|
|
422
479
|
if (!agentId) {
|
|
@@ -452,7 +509,10 @@ export async function buildSessionRecap(
|
|
|
452
509
|
forAgentId: normalizedForAgentId,
|
|
453
510
|
limit: 2,
|
|
454
511
|
});
|
|
455
|
-
const
|
|
512
|
+
const windowElapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow);
|
|
513
|
+
const elapsedMinutes = buildElapsedMinutes(visibleEvents, normalizedNow, {
|
|
514
|
+
startedAt: sessionMetadata?.createdAt,
|
|
515
|
+
});
|
|
456
516
|
const latestEvent = visibleEvents.length > 0 ? visibleEvents[visibleEvents.length - 1] : null;
|
|
457
517
|
const recapText = buildRecapText({
|
|
458
518
|
activeAgents,
|
|
@@ -481,6 +541,10 @@ export async function buildSessionRecap(
|
|
|
481
541
|
taskLedger,
|
|
482
542
|
snippets,
|
|
483
543
|
elapsedMinutes,
|
|
544
|
+
windowElapsedMinutes,
|
|
545
|
+
sessionStartedAt: sessionMetadata?.createdAt
|
|
546
|
+
? normalizeIsoTimestamp(sessionMetadata.createdAt, normalizedNow)
|
|
547
|
+
: null,
|
|
484
548
|
lastActorId: normalizeString(latestEvent?.agent?.id || latestEvent?.agentId) || null,
|
|
485
549
|
lastEventAt: latestEvent ? normalizeIsoTimestamp(latestEvent.ts, normalizedNow) : null,
|
|
486
550
|
},
|