ccdeck 3.38.1 → 3.38.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/web/assets/{BrowserWatchModal-D2iFbRdJ.js → BrowserWatchModal-DTjbVOvo.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-DaH3RkMw.js → UsageHistoryModal-BcXa5ICK.js} +1 -1
- package/dist/web/assets/index-6_mJZ_aP.css +1 -0
- package/dist/web/assets/index-ClJbck-M.js +309 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +8 -1
- package/src/server/account-projects.mjs +83 -20
- package/src/server/event-ring.mjs +10 -1
- package/src/server/event-routes.mjs +45 -1
- package/src/server/evicted-enrichment.mjs +213 -0
- package/src/server/lan-engine.mjs +16 -4
- package/src/server/transcript-scan.mjs +110 -30
- package/dist/web/assets/index-D-HX6Mea.css +0 -1
- package/dist/web/assets/index-DnnkOgia.js +0 -307
package/dist/web/index.html
CHANGED
|
@@ -64,8 +64,8 @@
|
|
|
64
64
|
stored === "light" || (stored !== "dark" && light) ? "light" : "dark");
|
|
65
65
|
})();
|
|
66
66
|
</script>
|
|
67
|
-
<script type="module" crossorigin src="/assets/index-
|
|
68
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
67
|
+
<script type="module" crossorigin src="/assets/index-ClJbck-M.js"></script>
|
|
68
|
+
<link rel="stylesheet" crossorigin href="/assets/index-6_mJZ_aP.css">
|
|
69
69
|
</head>
|
|
70
70
|
<body>
|
|
71
71
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccdeck",
|
|
3
|
-
"version": "3.38.
|
|
3
|
+
"version": "3.38.3",
|
|
4
4
|
"description": "Local dashboard for Claude Code and Codex CLI sessions: which Claude Code session needs input, every tool call and Claude Code subagent live, token cost and quota, several Claude accounts. Run npx ccdeck.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/release-notes.json
CHANGED
|
@@ -71,7 +71,14 @@
|
|
|
71
71
|
"3.36.9",
|
|
72
72
|
"3.37.0",
|
|
73
73
|
"3.38.0",
|
|
74
|
-
"3.38.1"
|
|
74
|
+
"3.38.1",
|
|
75
|
+
"3.38.2"
|
|
76
|
+
],
|
|
77
|
+
"3.38.3": [
|
|
78
|
+
{
|
|
79
|
+
"title": "🧾 Subagent costs count their whole reply",
|
|
80
|
+
"body": "A subagent's request was counted from the first moment of its reply, before most of it had been written, so much of its output was left out of the deck's token and cost counts, and a subagent turn in fast mode was priced at the standard rate. Each request is now counted from its finished reply. Days with subagents will read higher than before: that is the part that was missing, not a new charge. Projects counts its history again once after the update, so past days come out right too. Codex sessions on GPT-6 Sol, GPT-6.1 Sol and GPT-6 Luna now show a cost instead of not priced."
|
|
81
|
+
}
|
|
75
82
|
],
|
|
76
83
|
"3.37.1": [
|
|
77
84
|
{
|
|
@@ -33,7 +33,8 @@ import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from ".
|
|
|
33
33
|
// and a session's card cannot disagree about which lines billed anything, nor
|
|
34
34
|
// about the speed they were billed at — see transcript-scan.mjs.
|
|
35
35
|
import {
|
|
36
|
-
billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL,
|
|
36
|
+
billedSpeed, hasSpend, MAX_SPEEDS_PER_MODEL, UNRECOGNISED_SPEED,
|
|
37
|
+
requestBilling, streamingRequestId, BILL_LINE, REBILL_REQUEST, SKIP_LINE,
|
|
37
38
|
} from "./transcript-scan.mjs";
|
|
38
39
|
|
|
39
40
|
/** The pseudo-account for messages the swap log cannot place — everything
|
|
@@ -41,7 +42,7 @@ import {
|
|
|
41
42
|
* a per-account total is never quietly inflated by work that is not that
|
|
42
43
|
* account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
|
|
43
44
|
export const UNATTRIBUTED = "\u0000unattributed";
|
|
44
|
-
const STATE_VERSION =
|
|
45
|
+
const STATE_VERSION = 5;
|
|
45
46
|
|
|
46
47
|
/** Where the tally and cursors live, beside cswap-auto's own state. */
|
|
47
48
|
export function statePath(home = homedir()) {
|
|
@@ -104,6 +105,16 @@ function addInto(dst, src) {
|
|
|
104
105
|
return dst;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
/** Take `src`'s six counters back out of `dst`. */
|
|
109
|
+
function takeFrom(dst, src) {
|
|
110
|
+
dst.i -= src.i; dst.o -= src.o; dst.cr -= src.cr;
|
|
111
|
+
dst.cc -= src.cc; dst.c1h -= src.c1h; dst.c5m -= src.c5m;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isZero(c) {
|
|
115
|
+
return !c.i && !c.o && !c.cr && !c.cc && !c.c1h && !c.c5m;
|
|
116
|
+
}
|
|
117
|
+
|
|
107
118
|
/** The share of `counters` billed at `speed` (#754), created on first sight:
|
|
108
119
|
* `s[speed]`, a SUBSET of the counters it sits on, the way the 1h/5m split is
|
|
109
120
|
* of `cc`, so the totals read exactly as they did and the client prices the
|
|
@@ -112,11 +123,18 @@ function addInto(dst, src) {
|
|
|
112
123
|
* past the cap is kept as UNRECOGNISED_SPEED, which no rate table names, so it
|
|
113
124
|
* is counted and left unpriced rather than priced at the standard rate. */
|
|
114
125
|
function speedShareOf(counters, speed) {
|
|
126
|
+
const key = speedShareKey(counters, speed);
|
|
127
|
+
return counters.s[key];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The key `speed`'s share is kept under in `counters`, the share created on
|
|
131
|
+
* first sight — see speedShareOf. */
|
|
132
|
+
function speedShareKey(counters, speed) {
|
|
115
133
|
const shares = counters.s ??= {};
|
|
116
|
-
if (Object.hasOwn(shares, speed)) return
|
|
134
|
+
if (Object.hasOwn(shares, speed)) return speed;
|
|
117
135
|
const key = Object.keys(shares).length < MAX_SPEEDS_PER_MODEL ? speed : UNRECOGNISED_SPEED;
|
|
118
136
|
if (!Object.hasOwn(shares, key)) shares[key] = zero();
|
|
119
|
-
return
|
|
137
|
+
return key;
|
|
120
138
|
}
|
|
121
139
|
|
|
122
140
|
function zero() { return { i: 0, o: 0, cr: 0, cc: 0, c1h: 0, c5m: 0 }; }
|
|
@@ -223,22 +241,28 @@ export function projectPath(cwd) {
|
|
|
223
241
|
*
|
|
224
242
|
* `timeline` is the sorted swap log; the account is whoever it says was active
|
|
225
243
|
* at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
|
|
226
|
-
* `from` names the transcript's project directory — see folderOf
|
|
244
|
+
* `from` names the transcript's project directory — see folderOf — and, as
|
|
245
|
+
* `requests` and `path`, where the request this file is still waiting on a
|
|
246
|
+
* final record for is kept between passes.
|
|
227
247
|
*/
|
|
228
248
|
export function foldLine(tally, line, timeline, from) {
|
|
229
249
|
if (!line || !line.includes('"usage"')) return;
|
|
230
250
|
let obj = null;
|
|
231
251
|
try { obj = JSON.parse(line); } catch { return; }
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
|
|
252
|
+
// A request is billed once, from its final record, which in a subagent's
|
|
253
|
+
// file follows a streaming snapshot — see requestBilling. A call that keeps
|
|
254
|
+
// no requests for the file never sees one replaced, and bills block 0.
|
|
255
|
+
const requests = from?.requests ?? null;
|
|
256
|
+
const streaming = requests?.[from.path] ?? null;
|
|
257
|
+
const billing = requestBilling(obj, streaming?.id ?? null);
|
|
258
|
+
if (billing === SKIP_LINE) return;
|
|
236
259
|
const usage = obj?.message?.usage;
|
|
237
260
|
if (!usage || typeof usage !== "object") return;
|
|
238
261
|
const ts = Date.parse(obj?.timestamp);
|
|
239
262
|
if (!Number.isFinite(ts)) return;
|
|
240
263
|
const c = countersFrom(usage);
|
|
241
264
|
if (!hasSpend(asUsageTotals(c))) return; // a usage block that billed nothing
|
|
265
|
+
if (billing === REBILL_REQUEST) refund(tally, streaming);
|
|
242
266
|
const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
|
|
243
267
|
const lineCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : "";
|
|
244
268
|
const cwd = projectPath(folderOf(lineCwd, from)); // a worktree counts under the repo it checks out
|
|
@@ -251,7 +275,41 @@ export function foldLine(tally, line, timeline, from) {
|
|
|
251
275
|
// board prices it so (#754). Kept as a share rather than a model of its own,
|
|
252
276
|
// so a row's tokens and its model read exactly as before.
|
|
253
277
|
const speed = billedSpeed(obj);
|
|
254
|
-
|
|
278
|
+
const share = speed ? speedShareKey(counters, speed) : null;
|
|
279
|
+
if (share) addInto(counters.s[share], c);
|
|
280
|
+
if (billing === REBILL_REQUEST) dropIfEmpty(tally, streaming);
|
|
281
|
+
if (billing === BILL_LINE || !requests) return;
|
|
282
|
+
// What a request still streaming was charged, and where, so its final
|
|
283
|
+
// record can take that back, in this pass or a later one.
|
|
284
|
+
const id = streamingRequestId(obj);
|
|
285
|
+
if (id === null) delete requests[from.path];
|
|
286
|
+
else requests[from.path] = { id, at: [key, cwd, day, model], s: share, c };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Take a streaming request's charge back out of the row it was charged to,
|
|
290
|
+
* for its later record to charge in its place. A row pruned since has
|
|
291
|
+
* nothing left to take back. */
|
|
292
|
+
function refund(tally, { at: [key, cwd, day, model], s, c }) {
|
|
293
|
+
const counters = tally[key]?.[cwd]?.[day]?.[model];
|
|
294
|
+
if (!counters) return;
|
|
295
|
+
takeFrom(counters, c);
|
|
296
|
+
if (s && counters.s?.[s]) takeFrom(counters.s[s], c);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Remove what a refund left at zero — a speed share, or a whole row whose
|
|
300
|
+
* request's final record landed on another day or account — so the report
|
|
301
|
+
* never lists an empty row. */
|
|
302
|
+
function dropIfEmpty(tally, { at: [key, cwd, day, model], s }) {
|
|
303
|
+
const days = tally[key]?.[cwd];
|
|
304
|
+
const counters = days?.[day]?.[model];
|
|
305
|
+
if (!counters) return;
|
|
306
|
+
if (s && counters.s?.[s] && isZero(counters.s[s])) delete counters.s[s];
|
|
307
|
+
if (counters.s && !Object.keys(counters.s).length) delete counters.s;
|
|
308
|
+
if (!isZero(counters)) return;
|
|
309
|
+
delete days[day][model];
|
|
310
|
+
if (!Object.keys(days[day]).length) delete days[day];
|
|
311
|
+
if (!Object.keys(days).length) delete tally[key][cwd];
|
|
312
|
+
if (!Object.keys(tally[key]).length) delete tally[key];
|
|
255
313
|
}
|
|
256
314
|
|
|
257
315
|
/** Read the bytes of `path` from `start` to `size`, folding each COMPLETE line,
|
|
@@ -423,7 +481,7 @@ export function createProjectRollup({
|
|
|
423
481
|
setInterval: setIv = setInterval,
|
|
424
482
|
clearInterval: clearIv = clearInterval,
|
|
425
483
|
} = {}) {
|
|
426
|
-
let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {} };
|
|
484
|
+
let state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {} };
|
|
427
485
|
let timer = null;
|
|
428
486
|
let running = false;
|
|
429
487
|
let dirty = false;
|
|
@@ -437,14 +495,17 @@ export function createProjectRollup({
|
|
|
437
495
|
if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
|
|
438
496
|
state = disk;
|
|
439
497
|
state.folders ??= {};
|
|
440
|
-
|
|
498
|
+
state.requests ??= {};
|
|
499
|
+
} else if (disk && [1, 2, 3, 4].includes(disk.version)) {
|
|
441
500
|
// Version 1 counted every API content block; version 2 keyed each row
|
|
442
501
|
// by the line's own cwd, a row per folder the agent cd'd into (#1278);
|
|
443
502
|
// version 3 kept no speed, so every fast turn read at the standard
|
|
444
|
-
// rate
|
|
445
|
-
//
|
|
446
|
-
//
|
|
447
|
-
|
|
503
|
+
// rate; version 4 billed a subagent's request from its streaming
|
|
504
|
+
// snapshot, most of its output and its speed missing (#1883). None of
|
|
505
|
+
// them can be re-keyed, since it no longer knows which transcript a
|
|
506
|
+
// count came from. Keep the heartbeat so a restart gap remains fenced,
|
|
507
|
+
// but rebuild the tally from transcripts.
|
|
508
|
+
state = { version: STATE_VERSION, cursors: {}, tally: {}, folders: {}, requests: {}, lastAlive: disk.lastAlive };
|
|
448
509
|
dirty = true;
|
|
449
510
|
}
|
|
450
511
|
} catch { /* first run: empty state */ }
|
|
@@ -474,9 +535,9 @@ export function createProjectRollup({
|
|
|
474
535
|
// resync the cursor to the current end: the replaced content is lost, never
|
|
475
536
|
// counted twice. Transcripts append and do not shrink in normal use, so this
|
|
476
537
|
// is the rare, safe side to err on.
|
|
477
|
-
if (st.size < offset) { state.cursors[path] = st.size; dirty = true; return; }
|
|
538
|
+
if (st.size < offset) { state.cursors[path] = st.size; delete state.requests[path]; dirty = true; return; }
|
|
478
539
|
if (st.size <= offset) return;
|
|
479
|
-
const from = { slug, folders: state.folders };
|
|
540
|
+
const from = { slug, folders: state.folders, requests: state.requests, path };
|
|
480
541
|
let fh = null;
|
|
481
542
|
try {
|
|
482
543
|
fh = await open(path, "r");
|
|
@@ -504,9 +565,11 @@ export function createProjectRollup({
|
|
|
504
565
|
const timeline = await readSwapLog(swapLog);
|
|
505
566
|
const files = await listTranscripts(roots);
|
|
506
567
|
for (const [path, slug] of files) await foldFile(path, slug, timeline);
|
|
507
|
-
// Forget cursors
|
|
508
|
-
// directories with no transcript left, so
|
|
568
|
+
// Forget the cursors and streaming requests of files that are gone, and
|
|
569
|
+
// the folders of project directories with no transcript left, so none of
|
|
570
|
+
// the three maps grows forever.
|
|
509
571
|
for (const p of Object.keys(state.cursors)) if (!files.has(p)) { delete state.cursors[p]; dirty = true; }
|
|
572
|
+
for (const p of Object.keys(state.requests)) if (!files.has(p)) { delete state.requests[p]; dirty = true; }
|
|
510
573
|
const slugs = new Set(files.values());
|
|
511
574
|
for (const s of Object.keys(state.folders)) if (!slugs.has(s)) { delete state.folders[s]; dirty = true; }
|
|
512
575
|
// Heartbeat: record that the deck was alive now, so a later boot can see
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
// readers get a copy or an answer rather than the array. The bodies are
|
|
10
10
|
// unchanged.
|
|
11
11
|
import { ENVELOPE_CHARS, MAX_BUFFER, MAX_BUFFER_CHARS, MAX_RING_ENTRIES, isEnrichment, payloadChars } from "./ring-bounds.mjs";
|
|
12
|
+
// The newest enrichment of each kind per session that eviction takes off the
|
|
13
|
+
// head, for a page that connects after it went — see evicted-enrichment.mjs.
|
|
14
|
+
import { clearEvicted, noteEvicted } from "./evicted-enrichment.mjs";
|
|
12
15
|
|
|
13
16
|
// ─── The event ring buffer ─────────────────────────────────────────────────
|
|
14
17
|
// Its two bounds, MAX_BUFFER and MAX_BUFFER_CHARS, and the charge an event is
|
|
@@ -62,6 +65,8 @@ export function clearEventBuffer() {
|
|
|
62
65
|
events.length = 0;
|
|
63
66
|
bufferedChars = 0;
|
|
64
67
|
bufferedHookEvents = 0;
|
|
68
|
+
// And what eviction kept back from it: a cleared deck has nothing to resend.
|
|
69
|
+
clearEvicted();
|
|
65
70
|
}
|
|
66
71
|
|
|
67
72
|
/**
|
|
@@ -206,9 +211,13 @@ export function admitEvent(raw, source, receivedAt) {
|
|
|
206
211
|
drop++;
|
|
207
212
|
}
|
|
208
213
|
if (drop > 0) {
|
|
209
|
-
events.splice(0, drop);
|
|
214
|
+
const evicted = events.splice(0, drop);
|
|
210
215
|
bufferedChars -= freed;
|
|
211
216
|
bufferedHookEvents -= freedHookEvents;
|
|
217
|
+
// What leaves is gone for every page that had not been sent it — except
|
|
218
|
+
// the enrichment, whose newest value per session is kept back for those
|
|
219
|
+
// pages, because an idle session never sends it again.
|
|
220
|
+
for (const e of evicted) if (e[ENRICHMENT]) noteEvicted(e, e[CHARS]);
|
|
212
221
|
}
|
|
213
222
|
return evt;
|
|
214
223
|
}
|
|
@@ -22,6 +22,9 @@ import { noteLogWriter } from "./event-log.mjs";
|
|
|
22
22
|
import { deckProviders } from "./deck-scope.mjs";
|
|
23
23
|
// The names only the server may send — see ring-bounds.mjs.
|
|
24
24
|
import { isReservedEventName } from "./ring-bounds.mjs";
|
|
25
|
+
// The enrichment the ring evicted, handed to a page that connects behind its
|
|
26
|
+
// head — see withEvictedEnrichment.
|
|
27
|
+
import { evictedEnrichment } from "./evicted-enrichment.mjs";
|
|
25
28
|
// The SSE subscribers and the backpressure every frame to them is written
|
|
26
29
|
// under — see sse-clients.mjs.
|
|
27
30
|
import { dropSse, sseClients, trayClients, writeResume, writeSse } from "./sse-clients.mjs";
|
|
@@ -253,6 +256,11 @@ export function handleSse(req, res) {
|
|
|
253
256
|
// guard is `env.seq <= state.lastSeq`, so the gap costs it a step forward and
|
|
254
257
|
// nothing else. What the byte budget changed is how often and how far the
|
|
255
258
|
// head moves, not what happens to a client that lands behind it.
|
|
259
|
+
//
|
|
260
|
+
// One thing in the gap is not history: the newest model, name, usage and the
|
|
261
|
+
// rest of each session's last-value-wins enrichment, which an idle session
|
|
262
|
+
// never sends again. That is put back in front of the replay — see
|
|
263
|
+
// withEvictedEnrichment.
|
|
256
264
|
const asked = Number(req.headers["last-event-id"] ?? 0);
|
|
257
265
|
const lastId = Number.isFinite(asked) ? asked : 0;
|
|
258
266
|
|
|
@@ -311,7 +319,8 @@ async function resumeSse(req, res, lastId, { tray = false } = {}) {
|
|
|
311
319
|
// means a slow resumer pins one ring's worth of envelopes for as long as its
|
|
312
320
|
// pass lasts, which the budget bounds too: that pin used to be unbounded for
|
|
313
321
|
// the same reason the ring was.
|
|
314
|
-
|
|
322
|
+
// Behind the ring's head, the enrichment that fell off it goes first.
|
|
323
|
+
const batch = withEvictedEnrichment(ringSnapshot(), sentThrough);
|
|
315
324
|
for (const e of batch) {
|
|
316
325
|
if (e.seq <= sentThrough) continue;
|
|
317
326
|
if (closed || res.destroyed) return;
|
|
@@ -372,3 +381,38 @@ async function resumeSse(req, res, lastId, { tray = false } = {}) {
|
|
|
372
381
|
ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
|
|
373
382
|
if (!await flushed) dropSse(res);
|
|
374
383
|
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A replay pass's snapshot, with what this page lost to eviction put in front
|
|
387
|
+
* of it.
|
|
388
|
+
*
|
|
389
|
+
* When the ring's oldest event is newer than the next one this page needs —
|
|
390
|
+
* a fresh tab on a busy deck, a reload, a laptop waking — everything between
|
|
391
|
+
* was evicted, and a session's model, name, usage, context, activity line and
|
|
392
|
+
* job went with it if they were in there: they are sent when they change, so
|
|
393
|
+
* an idle session's card would be drawn without them for as long as it stayed
|
|
394
|
+
* idle. The newest value of each, for every session this snapshot holds an
|
|
395
|
+
* event of (the cards the page will draw), is put in front under its own seq
|
|
396
|
+
* (see evicted-enrichment.mjs), so the loop below sends it as one more replay
|
|
397
|
+
* frame, in order, before the ring. Every pass asks, against what this page
|
|
398
|
+
* has been sent so far: a slow replay the head overtook between passes is
|
|
399
|
+
* handed what it lost then too, and one that is caught up is handed nothing.
|
|
400
|
+
*
|
|
401
|
+
* The page holds a value that arrives before its card and gives it to the
|
|
402
|
+
* card when the card is made (parked-enrichment.ts).
|
|
403
|
+
*/
|
|
404
|
+
function withEvictedEnrichment(batch, sentThrough) {
|
|
405
|
+
if (batch.length === 0 || batch[0].seq <= sentThrough + 1) return batch;
|
|
406
|
+
try {
|
|
407
|
+
const sessions = new Set();
|
|
408
|
+
for (const e of batch) {
|
|
409
|
+
const sid = e.payload?.session_id;
|
|
410
|
+
if (typeof sid === "string" && sid) sessions.add(sid);
|
|
411
|
+
}
|
|
412
|
+
const lost = evictedEnrichment({ after: sentThrough, before: batch[0].seq, sessions });
|
|
413
|
+
return lost.length > 0 ? lost.concat(batch) : batch;
|
|
414
|
+
} catch {
|
|
415
|
+
// A page is never refused its replay for this.
|
|
416
|
+
return batch;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// The newest value of each last-value-wins enrichment the ring has evicted,
|
|
2
|
+
// per session — what a page that connects behind the ring's head is handed
|
|
3
|
+
// ahead of the replay, so it is not left without them.
|
|
4
|
+
//
|
|
5
|
+
// WHY THESE GO MISSING. A session's model, name, usage, context, activity line
|
|
6
|
+
// and background job (LAST_VALUE_WINS in ring-bounds.mjs) are each sent when
|
|
7
|
+
// they change, and the scanners gate most of them on that: an idle session's
|
|
8
|
+
// ModelObserved and SessionNamed went out once, near its start. The ring keeps
|
|
9
|
+
// the newest MAX_BUFFER hook events, so on a busy deck those events fall off
|
|
10
|
+
// its head while the session's later hook events are still held. A reload or a
|
|
11
|
+
// second tab is replayed what is left: the card is drawn, from its surviving
|
|
12
|
+
// hook events, with no model chip, no name and a stale token count, and nothing
|
|
13
|
+
// sends them again until the session does something.
|
|
14
|
+
//
|
|
15
|
+
// WHAT IS KEPT. Every enrichment envelope the ring evicts is folded here into
|
|
16
|
+
// the newest value of its kind for its session (foldEnrichment), and keeps the
|
|
17
|
+
// seq, time and source of the last one folded. A page whose Last-Event-ID is
|
|
18
|
+
// older than what was evicted is sent these BEFORE the ring, under their own
|
|
19
|
+
// seq — see withEvictedEnrichment in event-routes.mjs — so the page applies
|
|
20
|
+
// them exactly where the ring would have, and every newer event still in the
|
|
21
|
+
// ring lands on top of them as it would have. Nothing is pushed into the ring,
|
|
22
|
+
// nothing is broadcast to the pages already connected and nothing is logged;
|
|
23
|
+
// and because each keeps its own `receivedAt`, none of them reads to the page
|
|
24
|
+
// as the session being heard from just now.
|
|
25
|
+
//
|
|
26
|
+
// Fed by admitEvent (event-ring.mjs) as it evicts, and emptied with the ring by
|
|
27
|
+
// clearEventBuffer, so a Clear forgets these as it forgets the ring.
|
|
28
|
+
import { payloadChars } from "./ring-bounds.mjs";
|
|
29
|
+
|
|
30
|
+
/** The kinds kept: the session's own last-value-wins enrichment, each read by
|
|
31
|
+
* its applier in transcript-events.ts. Named here rather than read off
|
|
32
|
+
* LAST_VALUE_WINS, because the fold below is written for exactly these and a
|
|
33
|
+
* kind added there is not folded right by rules it was never written for. */
|
|
34
|
+
const KINDS = new Set([
|
|
35
|
+
"ModelObserved", "UsageObserved", "ContextObserved", "SessionNamed",
|
|
36
|
+
"ActivityObserved", "JobObserved",
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
/** The most sessions kept, least recently folded dropped first — the deck's
|
|
40
|
+
* own working number for the sessions it tracks (MAX_TRACKED_SESSIONS in
|
|
41
|
+
* session-tracking.mjs), well above the 200 cards a page draws. */
|
|
42
|
+
export const MAX_EVICTED_SESSIONS = 256;
|
|
43
|
+
|
|
44
|
+
/** What everything kept may weigh, in the characters payloadChars charges,
|
|
45
|
+
* and what one value may. Real values are a few kilobytes — a usage total
|
|
46
|
+
* split by model, a context breakdown with its memory files, a session's
|
|
47
|
+
* subagent models — so the cap on one value is only ever met by a payload no
|
|
48
|
+
* scanner wrote; `POST /api/event` takes enrichment names from anyone, and
|
|
49
|
+
* this must not become a second ring with no budget. */
|
|
50
|
+
export const MAX_EVICTED_CHARS = 8 * 1024 * 1024;
|
|
51
|
+
export const MAX_EVICTED_VALUE_CHARS = 256 * 1024;
|
|
52
|
+
|
|
53
|
+
// sid -> Map<hook_event_name, { seq, epoch, receivedAt, source, payload, chars }>,
|
|
54
|
+
// both in least-recently-folded order.
|
|
55
|
+
const kept = new Map();
|
|
56
|
+
let keptChars = 0;
|
|
57
|
+
|
|
58
|
+
const isObject = v => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
59
|
+
const nonEmpty = v => typeof v === "string" && v.trim() !== "";
|
|
60
|
+
|
|
61
|
+
/** The facts the reducer stamps off whatever payload carries them
|
|
62
|
+
* (stampSessionFacts), each with the test it is stamped under: anything else
|
|
63
|
+
* says nothing, so it never replaces a value that does. */
|
|
64
|
+
const STAMPED = [
|
|
65
|
+
["model_context_window", v => typeof v === "number" && v > 0],
|
|
66
|
+
["approval_policy", v => typeof v === "string" && v !== ""],
|
|
67
|
+
["context_tokens", v => typeof v === "number" && v >= 0],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const JOB_STATES = new Set(["working", "blocked", "done", "failed", "stopped"]);
|
|
71
|
+
const validActivity = a => isObject(a) && typeof a.text === "string" && a.text.trim() !== "" && Number.isFinite(a.at);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Two enrichment payloads of one kind, for one session, as the one payload
|
|
75
|
+
* that leaves a card where the two in a row would have.
|
|
76
|
+
*
|
|
77
|
+
* The rules are the reducer's own (transcript-events.ts), and the page folds
|
|
78
|
+
* what waits for a card by the same ones (parked-enrichment.ts):
|
|
79
|
+
* - a field the newer payload leaves out, or leaves undefined, says nothing;
|
|
80
|
+
* - the stamped facts, a ModelObserved's model and a SessionNamed's two
|
|
81
|
+
* names count only when they hold a value;
|
|
82
|
+
* - a context breakdown merges key by key, and subagent models by subagent;
|
|
83
|
+
* - a SessionNamed title that repeats the name is dropped, as the card drops it;
|
|
84
|
+
* - an activity line never goes backwards in time;
|
|
85
|
+
* - a job of `null` is the job gone, and one the reducer would refuse says nothing;
|
|
86
|
+
* - a usage total replaces the last one whole, its split by model included,
|
|
87
|
+
* and a UsageObserved without one says nothing about either.
|
|
88
|
+
*/
|
|
89
|
+
export function foldEnrichment(prev, next) {
|
|
90
|
+
if (!isObject(prev)) return next;
|
|
91
|
+
const out = { ...prev };
|
|
92
|
+
for (const [k, v] of Object.entries(next)) if (v !== undefined) out[k] = v;
|
|
93
|
+
const keep = k => { if (k in prev) out[k] = prev[k]; else delete out[k]; };
|
|
94
|
+
for (const [k, ok] of STAMPED) if (!ok(next[k])) keep(k);
|
|
95
|
+
switch (next.hook_event_name) {
|
|
96
|
+
case "ModelObserved":
|
|
97
|
+
if (!(typeof next.model === "string" && next.model !== "")) keep("model");
|
|
98
|
+
if (isObject(prev.subagentModels) || isObject(next.subagentModels)) {
|
|
99
|
+
out.subagentModels = {
|
|
100
|
+
...(isObject(prev.subagentModels) ? prev.subagentModels : {}),
|
|
101
|
+
...(isObject(next.subagentModels) ? next.subagentModels : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case "ContextObserved": {
|
|
106
|
+
if (!isObject(next.context)) { keep("context"); break; }
|
|
107
|
+
const ctx = isObject(prev.context) ? { ...prev.context } : {};
|
|
108
|
+
for (const [k, v] of Object.entries(next.context)) if (typeof v === "number" || Array.isArray(v)) ctx[k] = v;
|
|
109
|
+
out.context = ctx;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "SessionNamed": {
|
|
113
|
+
const name = nonEmpty(next.sessionName) ? next.sessionName : (prev.sessionName ?? null);
|
|
114
|
+
let title = nonEmpty(next.sessionTitle) ? next.sessionTitle : (prev.sessionTitle ?? null);
|
|
115
|
+
if (nonEmpty(name) && nonEmpty(title) && title.trim().toLowerCase() === name.trim().toLowerCase()) title = null;
|
|
116
|
+
out.sessionName = name;
|
|
117
|
+
out.sessionTitle = title;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "ActivityObserved":
|
|
121
|
+
if (!validActivity(next.activity) || (validActivity(prev.activity) && next.activity.at < prev.activity.at)) keep("activity");
|
|
122
|
+
break;
|
|
123
|
+
case "JobObserved":
|
|
124
|
+
if (!(next.job === null || (isObject(next.job) && JOB_STATES.has(next.job.state)))) keep("job");
|
|
125
|
+
break;
|
|
126
|
+
case "UsageObserved":
|
|
127
|
+
if (!isObject(next.usage)) { keep("usage"); keep("usageByModel"); }
|
|
128
|
+
else if (next.usageByModel === undefined) delete out.usageByModel;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function drop(sid, name) {
|
|
135
|
+
const kinds = kept.get(sid);
|
|
136
|
+
const entry = kinds?.get(name);
|
|
137
|
+
if (!entry) return;
|
|
138
|
+
keptChars -= entry.chars;
|
|
139
|
+
kinds.delete(name);
|
|
140
|
+
if (kinds.size === 0) kept.delete(sid);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function dropSession(sid) {
|
|
144
|
+
for (const name of [...(kept.get(sid)?.keys() ?? [])]) drop(sid, name);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* One envelope the ring has just evicted. Anything that is not one of KINDS,
|
|
149
|
+
* or names no session, is not kept. `charged` is what the ring charged the
|
|
150
|
+
* envelope, so a payload no scanner could have written is let go without
|
|
151
|
+
* being walked a second time — and takes the older value of its kind with it,
|
|
152
|
+
* since a page told the older one would be told something the deck has since
|
|
153
|
+
* heard replaced.
|
|
154
|
+
*/
|
|
155
|
+
export function noteEvicted(evt, charged = 0) {
|
|
156
|
+
const p = evt?.payload;
|
|
157
|
+
if (!isObject(p) || !KINDS.has(p.hook_event_name)) return;
|
|
158
|
+
const sid = p.session_id;
|
|
159
|
+
if (typeof sid !== "string" || sid === "") return;
|
|
160
|
+
const name = p.hook_event_name;
|
|
161
|
+
if (charged > MAX_EVICTED_VALUE_CHARS) { drop(sid, name); return; }
|
|
162
|
+
const prev = kept.get(sid)?.get(name);
|
|
163
|
+
const payload = foldEnrichment(prev?.payload, p);
|
|
164
|
+
const chars = payloadChars(payload);
|
|
165
|
+
drop(sid, name);
|
|
166
|
+
if (chars > MAX_EVICTED_VALUE_CHARS) return;
|
|
167
|
+
// Re-inserted, so both maps' own order is the order things were last folded
|
|
168
|
+
// in and the caps below drop the session heard from longest ago.
|
|
169
|
+
const kinds = kept.get(sid) ?? new Map();
|
|
170
|
+
kept.delete(sid);
|
|
171
|
+
kept.set(sid, kinds);
|
|
172
|
+
kinds.set(name, { seq: evt.seq, epoch: evt.epoch, receivedAt: evt.receivedAt, source: evt.source, payload, chars });
|
|
173
|
+
keptChars += chars;
|
|
174
|
+
while (kept.size > MAX_EVICTED_SESSIONS || keptChars > MAX_EVICTED_CHARS) {
|
|
175
|
+
const oldest = kept.keys().next().value;
|
|
176
|
+
if (oldest === undefined) break;
|
|
177
|
+
dropSession(oldest);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Forget everything kept — the ring was cleared. */
|
|
182
|
+
export function clearEvicted() {
|
|
183
|
+
kept.clear();
|
|
184
|
+
keptChars = 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* What a page is missing, as envelopes, oldest first: for each of `sessions`,
|
|
189
|
+
* the newest value of each kind whose last fold is newer than `after` (what
|
|
190
|
+
* the page was last sent) and older than `before` (the oldest seq the page is
|
|
191
|
+
* about to be replayed). A page that saw every one of them is sent none.
|
|
192
|
+
*
|
|
193
|
+
* @param {{ after: number, before: number, sessions: Set<string> }} range
|
|
194
|
+
*/
|
|
195
|
+
export function evictedEnrichment({ after, before, sessions }) {
|
|
196
|
+
const out = [];
|
|
197
|
+
for (const sid of sessions) {
|
|
198
|
+
const kinds = kept.get(sid);
|
|
199
|
+
if (!kinds) continue;
|
|
200
|
+
for (const e of kinds.values()) {
|
|
201
|
+
if (e.seq <= after || e.seq >= before) continue;
|
|
202
|
+
out.push({ seq: e.seq, epoch: e.epoch, receivedAt: e.receivedAt, source: e.source, payload: e.payload });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return out.sort((a, b) => a.seq - b.seq);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** What is kept right now, for the suite: sessions, values and their weight. */
|
|
209
|
+
export function evictedStats() {
|
|
210
|
+
let values = 0;
|
|
211
|
+
for (const kinds of kept.values()) values += kinds.size;
|
|
212
|
+
return { sessions: kept.size, values, chars: keptChars };
|
|
213
|
+
}
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
// anything.
|
|
42
42
|
import {
|
|
43
43
|
addTrusted, ANNOUNCE_MS, credentialAad, dropTrusted, identityFrom, open, peerWhy, plan, PRESENT_MS,
|
|
44
|
-
stillListed, transferChallenge, trustedPeer,
|
|
44
|
+
sharedWith, stillListed, transferChallenge, trustedPeer,
|
|
45
45
|
} from "./lan-sync.mjs";
|
|
46
46
|
import { mintInvite, readInvite } from "./lan-invite.mjs";
|
|
47
47
|
import { createInviteOffer } from "./lan-invite-offer.mjs";
|
|
@@ -1028,13 +1028,25 @@ export function createEngine({
|
|
|
1028
1028
|
had.met = had.met || row.met;
|
|
1029
1029
|
if (row.name && !had.name) had.name = row.name;
|
|
1030
1030
|
};
|
|
1031
|
+
// WHAT IS TICKED HERE AND NOT OFFERED THAT DECK, so its dialog draws no
|
|
1032
|
+
// lane going out for it: what an arrival ticked, when the accept switch
|
|
1033
|
+
// paired it — see sharedWith in lan-sync.mjs. The page never sees `onward`
|
|
1034
|
+
// or which pin a switch made, so the engine says the difference itself,
|
|
1035
|
+
// and only where there is one, which on most decks is nowhere.
|
|
1036
|
+
const notOfferedTo = id => {
|
|
1037
|
+
const offered = sharedWith(cfg, id);
|
|
1038
|
+
const held = cfg.shared.filter(key => !offered.includes(key));
|
|
1039
|
+
return held.length ? { notOffered: held } : {};
|
|
1040
|
+
};
|
|
1031
1041
|
// WHAT THE DECK'S OWN DIALOG DRAWS, by identity: the card it sent,
|
|
1032
|
-
// the logins it offered last,
|
|
1033
|
-
//
|
|
1034
|
-
// halves of a merged row read the same
|
|
1042
|
+
// the logins it offered last, when somebody here said yes, and what
|
|
1043
|
+
// this deck does not offer it. All four are keyed by the fingerprint
|
|
1044
|
+
// that proved itself, so both halves of a merged row read the same
|
|
1045
|
+
// answer.
|
|
1035
1046
|
const card = id => ({
|
|
1036
1047
|
...heardOf(id),
|
|
1037
1048
|
pairedAt: trustedPeer(cfg.trusted, id)?.at ?? null,
|
|
1049
|
+
...notOfferedTo(id),
|
|
1038
1050
|
});
|
|
1039
1051
|
for (const p of [...beacon.peers.values(), ...dials.rows()]) {
|
|
1040
1052
|
if (!stillListed(p, now())) continue;
|