sentinelayer-cli 0.40.0 → 0.42.0
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/README.md +22 -4
- package/package.json +4 -4
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +1094 -279
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +73 -0
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +120 -0
- package/src/engram/tools.js +152 -0
- package/src/engram/trust.js +105 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-auth.js +312 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +620 -0
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +26 -125
- package/src/session/event-identity.js +9 -2
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +246 -20
- package/src/session/listeners.js +71 -80
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +54 -26
- package/src/session/remote-hydrate.js +12 -20
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +789 -22
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/pump.js +8 -3
- package/src/session/wake/resolve-target.js +10 -5
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// tickets.js — the room's shared backlog from the CLI: list, claim, renew, release,
|
|
2
|
+
// submit, and the ticket event feed (API "Ticket Lease Contract (T2)").
|
|
3
|
+
//
|
|
4
|
+
// Credentials: every call resolves auth with resolveActiveAuthSession. Inside an
|
|
5
|
+
// admitted agent's command (the runCli choke point), that is the agent's admission
|
|
6
|
+
// credential and never the human token, so a ticket is claimed AS the agent.
|
|
7
|
+
//
|
|
8
|
+
// Lease state: a claim's { leaseId, fence, version, expiresAt } is kept per
|
|
9
|
+
// (session, ticket, identity) in a local file. Only a MISSING file means "no lease
|
|
10
|
+
// held here"; an unreadable one refuses, never reads as absent.
|
|
11
|
+
//
|
|
12
|
+
// Lost responses: a write's Idempotency-Key is stored BEFORE the request is sent. If
|
|
13
|
+
// the response is lost, re-running the same command reuses that key, and the server
|
|
14
|
+
// replays the original result instead of performing the operation again.
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import fsp from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
|
|
22
|
+
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
23
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
24
|
+
|
|
25
|
+
function normalizeString(value) {
|
|
26
|
+
return String(value ?? "").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function segment(value, label) {
|
|
30
|
+
const text = normalizeString(value).toLowerCase();
|
|
31
|
+
if (!/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(text)) throw new Error(`${label} is not a valid identifier.`);
|
|
32
|
+
return text.replace(/:/g, "%3a");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ticketLeasePath(sessionId, ticketId, identity, { homeDir } = {}) {
|
|
36
|
+
return path.join(
|
|
37
|
+
homeDir || os.homedir(),
|
|
38
|
+
".sentinelayer",
|
|
39
|
+
"agents",
|
|
40
|
+
segment(identity, "identity"),
|
|
41
|
+
"tickets",
|
|
42
|
+
segment(sessionId, "sessionId"),
|
|
43
|
+
`${segment(ticketId, "ticketId")}.json`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readState(filePath) {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await fsp.readFile(filePath, "utf8");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error && error.code === "ENOENT") return null;
|
|
53
|
+
throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
58
|
+
return parsed;
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function writeState(filePath, state) {
|
|
65
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
66
|
+
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
|
67
|
+
await fsp.writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
68
|
+
await fsp.rename(tmp, filePath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function auth(targetPath, resolveAuthSession) {
|
|
72
|
+
const session = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
|
|
73
|
+
if (!session?.token || !session?.apiUrl) throw new Error("Not authenticated for this session.");
|
|
74
|
+
return { token: session.token, apiUrl: normalizeString(session.apiUrl).replace(/\/+$/, "") };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ticketsUrl(apiUrl, sessionId, suffix = "") {
|
|
78
|
+
return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/tickets${suffix}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The Idempotency-Key for this logical write: reused until its response is stored. */
|
|
82
|
+
async function operationKey(statePath, operation, fingerprint) {
|
|
83
|
+
const state = (await readState(statePath)) || {};
|
|
84
|
+
const pending = state.pending;
|
|
85
|
+
if (pending && pending.operation === operation && pending.fingerprint === fingerprint && pending.key) {
|
|
86
|
+
return { key: pending.key, state };
|
|
87
|
+
}
|
|
88
|
+
const key = `${operation}-${randomUUID()}`;
|
|
89
|
+
const next = { ...state, pending: { operation, fingerprint, key } };
|
|
90
|
+
await writeState(statePath, next);
|
|
91
|
+
return { key, state: next };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function leaseState(response) {
|
|
95
|
+
return {
|
|
96
|
+
version: 1,
|
|
97
|
+
leaseId: response.lease.leaseId,
|
|
98
|
+
fence: response.lease.fence,
|
|
99
|
+
ticketVersion: response.version,
|
|
100
|
+
expiresAt: response.lease.expiresAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SNAPSHOT_PAGE = 200;
|
|
105
|
+
const SNAPSHOT_MAX_PAGES = 50;
|
|
106
|
+
|
|
107
|
+
function malformedSnapshot(reason) {
|
|
108
|
+
return new Error(`The ticket snapshot is malformed (${reason}); refusing a partial list.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One snapshot page, validated before anything in it is believed. The snapshot is keyset by
|
|
113
|
+
* ticket id: ids on a page are STRICTLY ascending and above the previous continuation (so
|
|
114
|
+
* none repeats), and the continuation is exactly the LAST id returned -- a token that jumps
|
|
115
|
+
* ahead would silently skip every ticket in between. Anything else is not a complete list.
|
|
116
|
+
*/
|
|
117
|
+
function validatedSnapshotPage(body, { first, afterId }) {
|
|
118
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw malformedSnapshot("a page is not an object");
|
|
119
|
+
if (!Array.isArray(body.items)) throw malformedSnapshot("a page has no items list");
|
|
120
|
+
if (typeof body.hasMore !== "boolean") throw malformedSnapshot("a page does not say whether there is more");
|
|
121
|
+
if (first && !(Number.isSafeInteger(body.cursor) && body.cursor >= 0)) {
|
|
122
|
+
throw malformedSnapshot("the first page has no room cursor to follow events from");
|
|
123
|
+
}
|
|
124
|
+
let previous = afterId;
|
|
125
|
+
for (const item of body.items) {
|
|
126
|
+
const id = item && typeof item === "object" ? item.id : null;
|
|
127
|
+
if (typeof id !== "string" || !id) throw malformedSnapshot("a ticket has no id");
|
|
128
|
+
if (previous && !(id > previous)) throw malformedSnapshot("ticket ids are not in ascending order");
|
|
129
|
+
previous = id;
|
|
130
|
+
}
|
|
131
|
+
if (body.hasMore) {
|
|
132
|
+
if (!body.items.length) throw malformedSnapshot("an empty page claims there is more");
|
|
133
|
+
const next = body.nextAfterId;
|
|
134
|
+
if (typeof next !== "string" || !next) throw malformedSnapshot("there is more but no way to continue");
|
|
135
|
+
if (next !== previous) throw malformedSnapshot("the continuation is not the last ticket returned");
|
|
136
|
+
}
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The room's COMPLETE backlog, from GET /tickets/snapshot paged to the end. The board read
|
|
142
|
+
* (GET /tickets) is a bounded, newest-first page: filtering it for "available" could miss
|
|
143
|
+
* claimable work beyond the page. `cursor` is the FIRST page's room cursor, taken with that
|
|
144
|
+
* page; `sl session ticket events --after <cursor>` continues from exactly there. A snapshot
|
|
145
|
+
* that cannot be completed is refused, never returned partial.
|
|
146
|
+
*/
|
|
147
|
+
export async function listTickets(
|
|
148
|
+
sessionId,
|
|
149
|
+
{
|
|
150
|
+
targetPath = process.cwd(),
|
|
151
|
+
available = false,
|
|
152
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
153
|
+
requestRead = requestJson,
|
|
154
|
+
pageSize = SNAPSHOT_PAGE,
|
|
155
|
+
maxPages = SNAPSHOT_MAX_PAGES,
|
|
156
|
+
} = {}
|
|
157
|
+
) {
|
|
158
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
159
|
+
const items = [];
|
|
160
|
+
let cursor = null;
|
|
161
|
+
let afterId = null;
|
|
162
|
+
let complete = false;
|
|
163
|
+
for (let page = 0; page < maxPages && !complete; page += 1) {
|
|
164
|
+
const query = new URLSearchParams({ limit: String(pageSize) });
|
|
165
|
+
if (afterId) query.set("afterId", afterId);
|
|
166
|
+
const body = validatedSnapshotPage(
|
|
167
|
+
await requestRead(`${ticketsUrl(apiUrl, sessionId, "/snapshot")}?${query}`, {
|
|
168
|
+
method: "GET",
|
|
169
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
170
|
+
}),
|
|
171
|
+
{ first: page === 0, afterId },
|
|
172
|
+
);
|
|
173
|
+
if (page === 0) cursor = body.cursor; // ONLY the first page's cursor is the baseline
|
|
174
|
+
items.push(...body.items);
|
|
175
|
+
if (!body.hasMore) complete = true;
|
|
176
|
+
else afterId = body.nextAfterId;
|
|
177
|
+
}
|
|
178
|
+
if (!complete) {
|
|
179
|
+
throw new Error(`The room has more than ${maxPages * pageSize} tickets; refusing a partial list.`);
|
|
180
|
+
}
|
|
181
|
+
// Newest first for people; the snapshot itself is in id order.
|
|
182
|
+
items.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")) || String(a.id).localeCompare(String(b.id)));
|
|
183
|
+
// Available = claimable by someone now: open, or working with an expired lease.
|
|
184
|
+
// Never blocked work: the MVP has no dependencies, and blocked is never available.
|
|
185
|
+
const filtered = available
|
|
186
|
+
? items.filter((t) => t.status === "open" || (t.status === "working" && t.lease && !t.lease.live))
|
|
187
|
+
: items;
|
|
188
|
+
return { items: filtered, cursor, count: filtered.length, complete: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function ticketEvents(
|
|
192
|
+
sessionId,
|
|
193
|
+
{ after = 0, targetPath = process.cwd(), resolveAuthSession = resolveActiveAuthSession, requestRead = requestJson } = {}
|
|
194
|
+
) {
|
|
195
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
196
|
+
return requestRead(`${ticketsUrl(apiUrl, sessionId, "/events")}?after=${encodeURIComponent(String(after))}`, {
|
|
197
|
+
method: "GET",
|
|
198
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function mutate(url, { token, key, operation, body, requestMutation }) {
|
|
203
|
+
return requestMutation(url, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
operationName: `session.ticket_${operation}`,
|
|
206
|
+
idempotencyKey: key,
|
|
207
|
+
headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": key },
|
|
208
|
+
body,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function claimTicket(
|
|
213
|
+
sessionId,
|
|
214
|
+
ticketId,
|
|
215
|
+
{
|
|
216
|
+
identity,
|
|
217
|
+
expectedVersion = null,
|
|
218
|
+
targetPath = process.cwd(),
|
|
219
|
+
homeDir,
|
|
220
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
221
|
+
requestMutation = requestJsonMutation,
|
|
222
|
+
} = {}
|
|
223
|
+
) {
|
|
224
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
225
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
226
|
+
const { key } = await operationKey(statePath, "claim", String(expectedVersion ?? "any"));
|
|
227
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/claim`), {
|
|
228
|
+
token, key, operation: "claim", requestMutation,
|
|
229
|
+
body: expectedVersion == null ? {} : { expectedVersion: Number(expectedVersion) },
|
|
230
|
+
});
|
|
231
|
+
// The lease is recorded here only if the server says it is held by the identity that
|
|
232
|
+
// asked: an agent by its own id, a person as a human. Anything else is not this lease.
|
|
233
|
+
const holder = response?.ticket?.lease;
|
|
234
|
+
const heldByIdentity =
|
|
235
|
+
identity === "human" ? holder?.holderKind === "human" : holder?.holderKind === "agent" && holder?.holder === identity;
|
|
236
|
+
if (!heldByIdentity) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`The server recorded this lease for ${holder?.holderKind || "nobody"} "${holder?.holder || ""}", not for "${identity}". ` +
|
|
239
|
+
"Nothing was recorded locally; release it from the web if it is not yours."
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
await writeState(statePath, leaseState(response));
|
|
243
|
+
return response;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function heldLease(statePath) {
|
|
247
|
+
const state = await readState(statePath);
|
|
248
|
+
if (!state || !state.leaseId) {
|
|
249
|
+
throw new Error("This identity holds no lease on that ticket here. Claim it first.");
|
|
250
|
+
}
|
|
251
|
+
return state;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function renewTicketLease(
|
|
255
|
+
sessionId,
|
|
256
|
+
ticketId,
|
|
257
|
+
{ identity, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
|
|
258
|
+
) {
|
|
259
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
260
|
+
const held = await heldLease(statePath);
|
|
261
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
262
|
+
const { key } = await operationKey(statePath, "renew", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
263
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/renew`), {
|
|
264
|
+
token, key, operation: "renew", requestMutation, body: { leaseId: held.leaseId, fence: held.fence },
|
|
265
|
+
});
|
|
266
|
+
await writeState(statePath, leaseState(response));
|
|
267
|
+
return response;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function releaseTicketLease(
|
|
271
|
+
sessionId,
|
|
272
|
+
ticketId,
|
|
273
|
+
{ identity, reason = null, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
|
|
274
|
+
) {
|
|
275
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
276
|
+
const held = await heldLease(statePath);
|
|
277
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
278
|
+
const { key } = await operationKey(statePath, "release", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
279
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/release`), {
|
|
280
|
+
token, key, operation: "release", requestMutation,
|
|
281
|
+
body: { leaseId: held.leaseId, fence: held.fence, expectedVersion: held.ticketVersion, ...(reason ? { reason } : {}) },
|
|
282
|
+
});
|
|
283
|
+
await fsp.rm(statePath, { force: true });
|
|
284
|
+
return response;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function submitTicket(
|
|
288
|
+
sessionId,
|
|
289
|
+
ticketId,
|
|
290
|
+
{
|
|
291
|
+
identity,
|
|
292
|
+
implementationSha = null,
|
|
293
|
+
evidence = [],
|
|
294
|
+
targetPath = process.cwd(),
|
|
295
|
+
homeDir,
|
|
296
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
297
|
+
requestMutation = requestJsonMutation,
|
|
298
|
+
} = {}
|
|
299
|
+
) {
|
|
300
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
301
|
+
const held = await heldLease(statePath);
|
|
302
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
303
|
+
const { key } = await operationKey(statePath, "submit", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
304
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/submit`), {
|
|
305
|
+
token, key, operation: "submit", requestMutation,
|
|
306
|
+
body: {
|
|
307
|
+
leaseId: held.leaseId,
|
|
308
|
+
fence: held.fence,
|
|
309
|
+
expectedVersion: held.ticketVersion,
|
|
310
|
+
...(implementationSha ? { implementationSha } : {}),
|
|
311
|
+
evidence,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
await fsp.rm(statePath, { force: true });
|
|
315
|
+
return response;
|
|
316
|
+
}
|
|
@@ -38,6 +38,7 @@ export async function pushSessionTitleToApi(
|
|
|
38
38
|
timeoutMs = DEFAULT_TITLE_SYNC_TIMEOUT_MS,
|
|
39
39
|
maxRetries = 1,
|
|
40
40
|
retryDelayMs = DEFAULT_TITLE_SYNC_RETRY_DELAY_MS,
|
|
41
|
+
autoRotate = true,
|
|
41
42
|
resolveAuthSession = resolveActiveAuthSession,
|
|
42
43
|
requestMutation = requestJsonMutation,
|
|
43
44
|
recordRemoteTitleSync = recordSessionRemoteTitleSync,
|
|
@@ -63,7 +64,7 @@ export async function pushSessionTitleToApi(
|
|
|
63
64
|
const session = await resolveAuthSession({
|
|
64
65
|
cwd: targetPath,
|
|
65
66
|
env,
|
|
66
|
-
autoRotate
|
|
67
|
+
autoRotate,
|
|
67
68
|
});
|
|
68
69
|
if (!session?.token || !session?.apiUrl) {
|
|
69
70
|
await recordRemoteTitleSync(normalizedSessionId, {
|
|
@@ -64,6 +64,45 @@ function optionalUsdCell(value, hasValue) {
|
|
|
64
64
|
return hasValue ? usdCell(value) : "-";
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
function booleanFlag(value) {
|
|
68
|
+
if (typeof value === "boolean") return value;
|
|
69
|
+
if (typeof value === "number" && Number.isFinite(value)) return value !== 0;
|
|
70
|
+
const text = normalize(value).toLowerCase();
|
|
71
|
+
if (!text) return null;
|
|
72
|
+
if (["1", "true", "t", "yes", "y"].includes(text)) return true;
|
|
73
|
+
if (["0", "false", "f", "no", "n"].includes(text)) return false;
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function hasHostedCustomerCost(bag) {
|
|
78
|
+
const camelFlag = booleanFlag(bag.hasCustomerCost);
|
|
79
|
+
if (camelFlag != null) return camelFlag;
|
|
80
|
+
const snakeFlag = booleanFlag(bag.has_customer_cost);
|
|
81
|
+
if (snakeFlag != null) return snakeFlag;
|
|
82
|
+
return [
|
|
83
|
+
bag.customerCostUsd,
|
|
84
|
+
bag.customer_cost_usd,
|
|
85
|
+
bag.billableCostUsd,
|
|
86
|
+
bag.billable_cost_usd,
|
|
87
|
+
].some((value) => value != null && value !== "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function rollupCostSummary(rollup = {}) {
|
|
91
|
+
const parts = [`provider ${usdCell(rollup.providerCostUsd)}`];
|
|
92
|
+
if (rollup.customerCostUsd != null) {
|
|
93
|
+
parts.push(`customer ${usdCell(rollup.customerCostUsd)}`);
|
|
94
|
+
}
|
|
95
|
+
const unpriced = nonNegativeInt(rollup.unpriced);
|
|
96
|
+
if (unpriced > 0) {
|
|
97
|
+
parts.push(`unpriced ${intCell(unpriced)}`);
|
|
98
|
+
}
|
|
99
|
+
const estimatedEntries = nonNegativeInt(rollup.estimatedEntries);
|
|
100
|
+
if (estimatedEntries > 0) {
|
|
101
|
+
parts.push(`estimated ${intCell(estimatedEntries)}`);
|
|
102
|
+
}
|
|
103
|
+
return parts.join(", ");
|
|
104
|
+
}
|
|
105
|
+
|
|
67
106
|
function timestampOnly(iso) {
|
|
68
107
|
if (!iso) return "";
|
|
69
108
|
const date = new Date(iso);
|
|
@@ -122,7 +161,7 @@ function rollupToJson(rollup) {
|
|
|
122
161
|
|
|
123
162
|
function hostedRollupToJson(rollup, fallbackLabel = "") {
|
|
124
163
|
const bag = object(rollup);
|
|
125
|
-
const hasCustomerCost =
|
|
164
|
+
const hasCustomerCost = hasHostedCustomerCost(bag);
|
|
126
165
|
return {
|
|
127
166
|
label: normalize(bag.label ?? bag.agentId ?? bag.agent_id ?? bag.action ?? fallbackLabel) || "unknown",
|
|
128
167
|
entries: nonNegativeInt(bag.entries ?? bag.entryCount ?? bag.entry_count ?? bag.count ?? bag.interactions),
|
|
@@ -412,7 +451,7 @@ export function renderSessionUsageSummary(report = {}) {
|
|
|
412
451
|
lines.push("Per agent:");
|
|
413
452
|
for (const agent of agents) {
|
|
414
453
|
lines.push(
|
|
415
|
-
`- ${agent.label}: ${intCell(agent.totalTokens)} tokens, ${intCell(agent.entries)} entries,
|
|
454
|
+
`- ${agent.label}: ${intCell(agent.totalTokens)} tokens, ${intCell(agent.entries)} entries, ${rollupCostSummary(agent)}`,
|
|
416
455
|
);
|
|
417
456
|
}
|
|
418
457
|
}
|
package/src/session/wake/pump.js
CHANGED
|
@@ -10,13 +10,17 @@
|
|
|
10
10
|
// daemon and make this untestable. The thin sentid runtime entrypoint adapts
|
|
11
11
|
// the real pollSessionEvents to the contract below; tests inject a fake.
|
|
12
12
|
//
|
|
13
|
-
// At-least-once end to end:
|
|
14
|
-
// last COMMITTED event (one whose seq the
|
|
13
|
+
// At-least-once end to end: when semantic events are present, the fetch cursor
|
|
14
|
+
// advances only to the cursor of the last COMMITTED event (one whose seq the
|
|
15
|
+
// dispatcher actually advanced past). An empty page may advance across a range
|
|
16
|
+
// the server proved contains no visible semantic events; this transport-only
|
|
17
|
+
// progress never advances the persisted committed-sequence cursor. A
|
|
15
18
|
// retryable/uncommitted failure stops the batch, the fetch cursor stays behind
|
|
16
19
|
// it, and the next poll re-delivers that event — so a transient wake failure is
|
|
17
20
|
// retried, never silently skipped.
|
|
18
21
|
|
|
19
22
|
import { readWakeCursor, writeWakeCursor } from "./cursor-store.js";
|
|
23
|
+
import { cursorAdvances } from "../sync-cursor.js";
|
|
20
24
|
|
|
21
25
|
function seqOf(event) {
|
|
22
26
|
const raw = event?.sequenceId ?? event?.seq ?? event?.payload?.sequenceId;
|
|
@@ -98,7 +102,8 @@ export function createWakePump({
|
|
|
98
102
|
await ensureSeeded();
|
|
99
103
|
const { events = [], cursor: latestCursor = null } = (await pollEvents(sessionId, { afterCursor: fetchCursor })) || {};
|
|
100
104
|
if (!Array.isArray(events) || events.length === 0) {
|
|
101
|
-
|
|
105
|
+
const next = cursorAdvances(latestCursor, fetchCursor) ? latestCursor : fetchCursor;
|
|
106
|
+
return { fetchCursor: next, results: [], idle: true };
|
|
102
107
|
}
|
|
103
108
|
const results = await dispatcher.dispatchBatch(events, deps);
|
|
104
109
|
const committedSeq = dispatcher.getCursor();
|
|
@@ -16,8 +16,10 @@
|
|
|
16
16
|
// transitive graph (auth/sync/`open`/...) into the wake daemon. The matcher
|
|
17
17
|
// here is small enough to own.
|
|
18
18
|
|
|
19
|
+
import { messageRevision, sessionMessageEditSnapshot, sessionMessageRoutingEvent } from "../message-edits.js";
|
|
20
|
+
|
|
19
21
|
const BROADCAST_RECIPIENTS = new Set(["*", "all", "broadcast", "everyone", "anyone", "agents", "all-agents"]);
|
|
20
|
-
const DEFAULT_WAKE_EVENT_TYPES = new Set(["session_message", "help_request"]);
|
|
22
|
+
const DEFAULT_WAKE_EVENT_TYPES = new Set(["session_message", "help_request", "session_message_edited"]);
|
|
21
23
|
const MAX_WAKE_MESSAGE_CHARS = 16_000;
|
|
22
24
|
|
|
23
25
|
function requireNonEmptyString(value, label) {
|
|
@@ -98,7 +100,7 @@ function defaultFormatMessage(event, agentId) {
|
|
|
98
100
|
const author = agentIdOf(event) || "unknown";
|
|
99
101
|
const text = event?.payload?.message ?? event?.message ?? "";
|
|
100
102
|
const body = typeof text === "string" ? text : "";
|
|
101
|
-
const head = `Senti wake for ${agentId}: new message from ${author}.`;
|
|
103
|
+
const head = `Senti wake for ${agentId}: ${event.event === "session_message_edited" ? "edited message" : "new message"} from ${author}.`;
|
|
102
104
|
const combined = body ? `${head}\n\n${body}` : head;
|
|
103
105
|
return combined.length > MAX_WAKE_MESSAGE_CHARS ? combined.slice(0, MAX_WAKE_MESSAGE_CHARS) : combined;
|
|
104
106
|
}
|
|
@@ -128,18 +130,21 @@ export function createResolveTarget({
|
|
|
128
130
|
// Only wake on real message events; acks/reactions/views/system are skips.
|
|
129
131
|
const type = eventTypeOf(event);
|
|
130
132
|
if (!type || !wakeEventTypes.has(type)) return null;
|
|
133
|
+
if (type === "session_message" && messageRevision(event) > 1) return null;
|
|
134
|
+
if (type === "session_message_edited" && !sessionMessageEditSnapshot(event)) return null;
|
|
135
|
+
const routed = sessionMessageRoutingEvent(event);
|
|
131
136
|
|
|
132
137
|
// No self-wake: never wake the agent on its own message. Normalize both
|
|
133
138
|
// sides (handles @-prefix / casing / punctuation) so the loop-guard can't
|
|
134
139
|
// be slipped by a non-canonical author id.
|
|
135
|
-
const author = agentIdOf(
|
|
140
|
+
const author = agentIdOf(routed);
|
|
136
141
|
if (author && normalizeComparableId(author) === selfLower) return null;
|
|
137
142
|
|
|
138
143
|
// Routing: directed-to-me / broadcast / untargeted-room wakes us; a message
|
|
139
144
|
// aimed at a different specific agent is intentionally unroutable.
|
|
140
|
-
if (!matchesAgent(
|
|
145
|
+
if (!matchesAgent(routed, selfLower)) return null;
|
|
141
146
|
|
|
142
|
-
return { host: hostName, sessionId: resumeId, message: formatMessage(event, selfId) };
|
|
147
|
+
return { host: hostName, sessionId: resumeId, message: formatMessage({ ...routed, event: type }, selfId) };
|
|
143
148
|
};
|
|
144
149
|
}
|
|
145
150
|
|