ccdeck 3.26.5 → 3.27.2
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/dist/web/assets/{BrowserWatchModal-B6MuDGvq.js → BrowserWatchModal-dGoIoVqT.js} +1 -1
- package/dist/web/assets/{UsageHistoryModal-BJA3mUzs.js → UsageHistoryModal-zfjtA5Nv.js} +1 -1
- package/dist/web/assets/index-CQSnj_z2.js +297 -0
- package/dist/web/assets/index-D_RU_J-E.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/release-notes.json +18 -0
- package/src/server/account-projects.mjs +422 -0
- package/src/server/best-of-nostalgia.mjs +63 -0
- package/src/server/cafe-music-bgm.mjs +63 -0
- package/src/server/cswap-auto.mjs +9 -0
- package/src/server/good-life-radio.mjs +63 -0
- package/src/server/index.mjs +114 -0
- package/src/server/live-radio-mix.mjs +56 -0
- package/src/server/lofi-girl.mjs +76 -0
- package/src/server/swap-log.mjs +188 -0
- package/dist/web/assets/index-D4fPrztm.js +0 -297
- package/dist/web/assets/index-DlaHgzye.css +0 -1
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
// How much each account worked in each project — the rollup behind the
|
|
2
|
+
// account card's "Projects" report.
|
|
3
|
+
//
|
|
4
|
+
// The account cards show account-wide rate-limit utilisation, which has no
|
|
5
|
+
// project dimension and cannot be sliced. This is the other axis: for one
|
|
6
|
+
// account, the tokens it spent per project, which the web side prices with the
|
|
7
|
+
// board's own pricing table (pricing.ts). Cost is NOT computed here — the
|
|
8
|
+
// server is plain .mjs with no build step and cannot import the .ts price
|
|
9
|
+
// table, and keeping one source of prices beats a second copy that drifts. So
|
|
10
|
+
// this file tallies TOKENS per (account, project, day, model) and the client
|
|
11
|
+
// multiplies by the rates it already owns.
|
|
12
|
+
//
|
|
13
|
+
// Attribution is per MESSAGE, not per session. A running session migrates to a
|
|
14
|
+
// newly-activated account on its next message, so a whole session can span two
|
|
15
|
+
// accounts; only the message's own timestamp, matched against the swap log,
|
|
16
|
+
// says who was active when it was billed. Each transcript line carries, in one
|
|
17
|
+
// JSON object, its `timestamp`, its `message.model` and its `message.usage`
|
|
18
|
+
// token counts, and its `cwd` — everything a row needs.
|
|
19
|
+
//
|
|
20
|
+
// The scan is incremental: a byte cursor per transcript file means a pass folds
|
|
21
|
+
// only the lines appended since the last one, so opening the report is a read
|
|
22
|
+
// of a small on-disk tally rather than a walk of every transcript.
|
|
23
|
+
import { open, stat, readdir, readFile, writeFile, mkdir } from "node:fs/promises";
|
|
24
|
+
import { homedir } from "node:os";
|
|
25
|
+
import { join, resolve, basename, dirname } from "node:path";
|
|
26
|
+
import { StringDecoder } from "node:string_decoder";
|
|
27
|
+
import { renameWithRetry } from "./installer.mjs";
|
|
28
|
+
import { claudeConfigDir } from "./claude-dir.mjs";
|
|
29
|
+
import { accountKey } from "./lan-sync.mjs";
|
|
30
|
+
import { readSwapLog, accountAtTime, trackedSince, seedActive, markGap } from "./swap-log.mjs";
|
|
31
|
+
|
|
32
|
+
/** The pseudo-account for messages the swap log cannot place — everything
|
|
33
|
+
* before tracking began, or a gap. Shown once, apart from any real account, so
|
|
34
|
+
* a per-account total is never quietly inflated by work that is not that
|
|
35
|
+
* account's. A NUL keeps it from ever colliding with a real `email@@org` key. */
|
|
36
|
+
export const UNATTRIBUTED = "\u0000unattributed";
|
|
37
|
+
const STATE_VERSION = 2;
|
|
38
|
+
|
|
39
|
+
/** Where the tally and cursors live, beside cswap-auto's own state. */
|
|
40
|
+
export function statePath(home = homedir()) {
|
|
41
|
+
return join(home, ".agents-deck", "account-projects.json");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const MAX_CHUNK = 1 << 20; // 1 MiB reads, so a cold 100 MB transcript never loads whole
|
|
45
|
+
const RETAIN_DAYS = 60; // covers the 30-day window with margin; older days are pruned
|
|
46
|
+
const DEFAULT_INTERVAL_MS = 20_000; // a pass every 20 s; each is stat + append-only reads
|
|
47
|
+
const HEARTBEAT_MS = 300_000; // persist "last seen alive" at most this often when idle
|
|
48
|
+
const GAP_MS = 600_000; // a boot more than this after the last heartbeat means the
|
|
49
|
+
// deck was down; that stretch is fenced off as unattributed
|
|
50
|
+
|
|
51
|
+
/** The two directories Claude Code writes transcripts under: the configured one
|
|
52
|
+
* and the default, since a deck launched from a desktop shortcut may resolve
|
|
53
|
+
* CLAUDE_CONFIG_DIR differently than the `claude` process that wrote the file
|
|
54
|
+
* (the same two-root reason index.mjs states for the hook path). */
|
|
55
|
+
export function transcriptRoots(home = homedir(), env = process.env) {
|
|
56
|
+
const roots = [resolve(claudeConfigDir(env, home), "projects")];
|
|
57
|
+
const def = resolve(home, ".claude", "projects");
|
|
58
|
+
if (!roots.includes(def)) roots.push(def);
|
|
59
|
+
return roots;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function num(v) {
|
|
63
|
+
const n = Number(v);
|
|
64
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The six token counters a transcript's `usage` block carries, in the compact
|
|
68
|
+
* keys the tally stores. Shape mirrors web's TokenUsage so the client maps it
|
|
69
|
+
* straight onto costForUsage. */
|
|
70
|
+
export function countersFrom(usage) {
|
|
71
|
+
const cc = (usage && typeof usage.cache_creation === "object" && usage.cache_creation) || {};
|
|
72
|
+
return {
|
|
73
|
+
i: num(usage?.input_tokens),
|
|
74
|
+
o: num(usage?.output_tokens),
|
|
75
|
+
cr: num(usage?.cache_read_input_tokens),
|
|
76
|
+
cc: num(usage?.cache_creation_input_tokens),
|
|
77
|
+
c1h: num(cc.ephemeral_1h_input_tokens),
|
|
78
|
+
c5m: num(cc.ephemeral_5m_input_tokens),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function addInto(dst, src) {
|
|
83
|
+
dst.i += src.i; dst.o += src.o; dst.cr += src.cr;
|
|
84
|
+
dst.cc += src.cc; dst.c1h += src.c1h; dst.c5m += src.c5m;
|
|
85
|
+
return dst;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function zero() { return { i: 0, o: 0, cr: 0, cc: 0, c1h: 0, c5m: 0 }; }
|
|
89
|
+
|
|
90
|
+
/** Local calendar day of a timestamp, `YYYY-MM-DD`. Local because that is the
|
|
91
|
+
* day a person means by "today", and how ccusage buckets its daily rollup. The
|
|
92
|
+
* string sorts chronologically, so a window is a `>=` on it. */
|
|
93
|
+
export function localDay(ts) {
|
|
94
|
+
const d = new Date(ts);
|
|
95
|
+
const y = d.getFullYear();
|
|
96
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
97
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
98
|
+
return `${y}-${m}-${day}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The earliest day a window of `days` includes, counting today as day 1. */
|
|
102
|
+
export function windowCutoff(days, now = Date.now()) {
|
|
103
|
+
if (!days || days <= 0) return null;
|
|
104
|
+
return localDay(now - (days - 1) * 86_400_000);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** CC encodes a cwd `/Users/x/y` as the folder `-Users-x-y`. The reverse is
|
|
108
|
+
* lossy — a real dash in a directory name is indistinguishable from a
|
|
109
|
+
* separator — so this is only the fallback for a line that carried no `cwd`;
|
|
110
|
+
* a real dash in a path is rare and only ever mislabels that one project. */
|
|
111
|
+
function cwdFromSlug(slug) {
|
|
112
|
+
if (!slug || slug === "." ) return "";
|
|
113
|
+
return "/" + slug.replace(/^-+/, "").replace(/-/g, "/");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The project a cwd belongs to. A git worktree lives at
|
|
118
|
+
* `<repo>/.claude/worktrees/<name>`, so work done in one is counted under the
|
|
119
|
+
* repo it is a checkout of — otherwise the same project splits into a row per
|
|
120
|
+
* worktree ("agents-deck" and "account-projects" for one repo, #1200 review).
|
|
121
|
+
* Handles both path separators, for a Windows cwd. Anything not in a worktree
|
|
122
|
+
* is returned unchanged.
|
|
123
|
+
*/
|
|
124
|
+
export function projectPath(cwd) {
|
|
125
|
+
if (typeof cwd !== "string" || !cwd) return "";
|
|
126
|
+
// Strip `.claude/worktrees` and anything under it — the worktree name, a
|
|
127
|
+
// path inside it, or nothing at all (a session run in the worktrees dir).
|
|
128
|
+
return cwd.replace(/[\\/]\.claude[\\/]worktrees(?:[\\/].*)?$/, "");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Fold one transcript line into the tally. A line with no usage block, no
|
|
133
|
+
* parseable timestamp, or that is not JSON leaves the tally untouched — the
|
|
134
|
+
* same tolerance the whole-file scanners have.
|
|
135
|
+
*
|
|
136
|
+
* `timeline` is the sorted swap log; the account is whoever it says was active
|
|
137
|
+
* at this line's timestamp, or UNATTRIBUTED when the timestamp precedes it.
|
|
138
|
+
*/
|
|
139
|
+
export function foldLine(tally, line, timeline, fallbackCwd) {
|
|
140
|
+
if (!line || !line.includes('"usage"')) return;
|
|
141
|
+
let obj = null;
|
|
142
|
+
try { obj = JSON.parse(line); } catch { return; }
|
|
143
|
+
// Claude writes one assistant record for each content block in a request.
|
|
144
|
+
// Every block repeats the request's usage, so only block 0 is billable for
|
|
145
|
+
// this rollup. Older records without the field are kept compatible.
|
|
146
|
+
if (obj?.apiBlockIndex !== undefined && obj.apiBlockIndex !== 0) return;
|
|
147
|
+
const usage = obj?.message?.usage;
|
|
148
|
+
if (!usage || typeof usage !== "object") return;
|
|
149
|
+
const ts = Date.parse(obj?.timestamp);
|
|
150
|
+
if (!Number.isFinite(ts)) return;
|
|
151
|
+
const c = countersFrom(usage);
|
|
152
|
+
if (!(c.i || c.o || c.cr || c.cc)) return; // a usage block that billed nothing
|
|
153
|
+
const model = typeof obj?.message?.model === "string" ? obj.message.model : "";
|
|
154
|
+
const rawCwd = (typeof obj?.cwd === "string" && obj.cwd) ? obj.cwd : (fallbackCwd || "");
|
|
155
|
+
const cwd = projectPath(rawCwd); // a worktree counts under the repo it checks out
|
|
156
|
+
const who = accountAtTime(timeline, ts);
|
|
157
|
+
const key = who ? accountKey(who.email, who.orgUuid) : UNATTRIBUTED;
|
|
158
|
+
const day = localDay(ts);
|
|
159
|
+
const proj = (((tally[key] ??= {})[cwd] ??= {})[day] ??= {});
|
|
160
|
+
addInto(proj[model] ??= zero(), c);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Read the bytes of `path` from `start` to `size`, folding each COMPLETE line,
|
|
164
|
+
* and return the byte offset of the last newline consumed. A partial final
|
|
165
|
+
* line (an append still in flight) is left for the next pass. StringDecoder
|
|
166
|
+
* carries a multibyte character split across a chunk boundary. */
|
|
167
|
+
async function foldAppended(fh, start, size, onLine) {
|
|
168
|
+
const dec = new StringDecoder("utf8");
|
|
169
|
+
let offset = start;
|
|
170
|
+
let carry = "";
|
|
171
|
+
const buf = Buffer.allocUnsafe(Math.min(MAX_CHUNK, Math.max(1, size - start)));
|
|
172
|
+
while (offset < size) {
|
|
173
|
+
const want = Math.min(buf.length, size - offset);
|
|
174
|
+
const { bytesRead } = await fh.read(buf, 0, want, offset);
|
|
175
|
+
if (bytesRead <= 0) break;
|
|
176
|
+
offset += bytesRead;
|
|
177
|
+
carry += dec.write(buf.subarray(0, bytesRead));
|
|
178
|
+
let nl;
|
|
179
|
+
while ((nl = carry.indexOf("\n")) >= 0) {
|
|
180
|
+
onLine(carry.slice(0, nl));
|
|
181
|
+
carry = carry.slice(nl + 1);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
carry += dec.end();
|
|
185
|
+
// Consumed up to the last newline; the leftover partial line's bytes stay
|
|
186
|
+
// uncounted so the next pass reads it whole.
|
|
187
|
+
return size - Buffer.byteLength(carry, "utf8");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Every `.jsonl` under the transcript roots, de-duplicated. */
|
|
191
|
+
async function listTranscripts(roots) {
|
|
192
|
+
const out = new Set();
|
|
193
|
+
for (const root of roots) {
|
|
194
|
+
let ents = null;
|
|
195
|
+
try { ents = await readdir(root, { recursive: true, withFileTypes: true }); }
|
|
196
|
+
catch { continue; }
|
|
197
|
+
for (const e of ents) {
|
|
198
|
+
if (!e.isFile() || !e.name.endsWith(".jsonl")) continue;
|
|
199
|
+
out.add(join(e.parentPath ?? e.path ?? root, e.name));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return [...out];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Drop day buckets older than the retention window and prune the empty
|
|
206
|
+
* branches left behind, so the file cannot grow without bound. */
|
|
207
|
+
function prune(tally, now = Date.now()) {
|
|
208
|
+
const keep = windowCutoff(RETAIN_DAYS, now);
|
|
209
|
+
if (!keep) return;
|
|
210
|
+
for (const acct of Object.keys(tally)) {
|
|
211
|
+
const projects = tally[acct];
|
|
212
|
+
for (const cwd of Object.keys(projects)) {
|
|
213
|
+
const days = projects[cwd];
|
|
214
|
+
for (const day of Object.keys(days)) if (day < keep) delete days[day];
|
|
215
|
+
if (!Object.keys(days).length) delete projects[cwd];
|
|
216
|
+
}
|
|
217
|
+
if (!Object.keys(projects).length) delete tally[acct];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Sum a project's per-day, per-model counters into one `{ model: counters }`
|
|
222
|
+
* over the days at or after `cutoff` (all days when cutoff is null). */
|
|
223
|
+
function sumModels(daysMap, cutoff) {
|
|
224
|
+
const models = {};
|
|
225
|
+
let any = false;
|
|
226
|
+
for (const day of Object.keys(daysMap)) {
|
|
227
|
+
if (cutoff && day < cutoff) continue;
|
|
228
|
+
for (const [model, c] of Object.entries(daysMap[day])) {
|
|
229
|
+
addInto(models[model] ??= zero(), c);
|
|
230
|
+
any = true;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return any ? models : null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* The report for one account over a window: its projects (each a per-model
|
|
238
|
+
* token breakdown for the client to price), plus the global unattributed
|
|
239
|
+
* bucket for the same window, and when tracking began.
|
|
240
|
+
*
|
|
241
|
+
* `days` of 0 (or null) means all retained days. Projects with no tokens in the
|
|
242
|
+
* window are dropped. Ordering is left to the client, which prices first.
|
|
243
|
+
*/
|
|
244
|
+
export function reportFrom(tally, timeline, key, days, now = Date.now()) {
|
|
245
|
+
const cutoff = windowCutoff(days, now);
|
|
246
|
+
const projects = [];
|
|
247
|
+
for (const [cwd, daysMap] of Object.entries(tally[key] ?? {})) {
|
|
248
|
+
const models = sumModels(daysMap, cutoff);
|
|
249
|
+
if (models) projects.push({ path: cwd, name: basename(cwd) || cwd || "(unknown)", models });
|
|
250
|
+
}
|
|
251
|
+
// Unattributed is not any one account's, so it is collapsed across projects
|
|
252
|
+
// into a single bucket shown apart from the account's own totals.
|
|
253
|
+
const unModels = {};
|
|
254
|
+
let unAny = false;
|
|
255
|
+
for (const daysMap of Object.values(tally[UNATTRIBUTED] ?? {})) {
|
|
256
|
+
const m = sumModels(daysMap, cutoff);
|
|
257
|
+
if (m) { for (const [model, c] of Object.entries(m)) addInto(unModels[model] ??= zero(), c); unAny = true; }
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
trackedSince: trackedSince(timeline),
|
|
261
|
+
days: days || 0,
|
|
262
|
+
projects,
|
|
263
|
+
unattributed: unAny ? unModels : null,
|
|
264
|
+
daily: dailyFrom(tally, key, cutoff),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The same window, sliced by day, for the per-day chart: for each day that has
|
|
270
|
+
* work, the per-project and the unattributed model counters. The unattributed
|
|
271
|
+
* bucket is kept per day too — not to draw it, but so the client can reconcile
|
|
272
|
+
* each day to ccusage over a complete denominator.
|
|
273
|
+
*/
|
|
274
|
+
export function dailyFrom(tally, key, cutoff) {
|
|
275
|
+
const byDay = new Map();
|
|
276
|
+
const slot = day => { let s = byDay.get(day); if (!s) { s = { projects: {}, un: {} }; byDay.set(day, s); } return s; };
|
|
277
|
+
for (const [cwd, daysMap] of Object.entries(tally[key] ?? {})) {
|
|
278
|
+
for (const [day, models] of Object.entries(daysMap)) {
|
|
279
|
+
if (cutoff && day < cutoff) continue;
|
|
280
|
+
const dst = (slot(day).projects[cwd] ??= {});
|
|
281
|
+
for (const [m, c] of Object.entries(models)) addInto(dst[m] ??= zero(), c);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Unattributed folds ONLY into days that already have attributed work — it
|
|
285
|
+
// feeds the per-day reconciliation denominator but must not raise a column of
|
|
286
|
+
// its own, or the chart grows an empty bar for every day of pre-tracking
|
|
287
|
+
// history (all of it unattributed).
|
|
288
|
+
for (const daysMap of Object.values(tally[UNATTRIBUTED] ?? {})) {
|
|
289
|
+
for (const [day, models] of Object.entries(daysMap)) {
|
|
290
|
+
const s = byDay.get(day);
|
|
291
|
+
if (!s) continue;
|
|
292
|
+
for (const [m, c] of Object.entries(models)) addInto(s.un[m] ??= zero(), c);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return [...byDay.entries()]
|
|
296
|
+
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
|
297
|
+
.map(([day, s]) => ({
|
|
298
|
+
day,
|
|
299
|
+
projects: Object.entries(s.projects).map(([path, models]) => ({ path, models })),
|
|
300
|
+
unattributed: Object.keys(s.un).length ? s.un : null,
|
|
301
|
+
}));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The rollup engine: a periodic incremental scan, an on-disk tally, and a
|
|
306
|
+
* synchronous `report`. Everything is injectable so the pass, the paths and the
|
|
307
|
+
* clock can be driven from a test without touching the real machine.
|
|
308
|
+
*/
|
|
309
|
+
export function createProjectRollup({
|
|
310
|
+
now = Date.now,
|
|
311
|
+
intervalMs = DEFAULT_INTERVAL_MS,
|
|
312
|
+
roots = transcriptRoots(),
|
|
313
|
+
state: stateFile = statePath(),
|
|
314
|
+
swapLog, // path override for readSwapLog / seedActive
|
|
315
|
+
storeRoot, // claude-swap store root the seed reads (tests inject a bogus one)
|
|
316
|
+
setInterval: setIv = setInterval,
|
|
317
|
+
clearInterval: clearIv = clearInterval,
|
|
318
|
+
} = {}) {
|
|
319
|
+
let state = { version: STATE_VERSION, cursors: {}, tally: {} };
|
|
320
|
+
let timer = null;
|
|
321
|
+
let running = false;
|
|
322
|
+
let dirty = false;
|
|
323
|
+
let loaded = false;
|
|
324
|
+
|
|
325
|
+
async function load() {
|
|
326
|
+
if (loaded) return;
|
|
327
|
+
loaded = true;
|
|
328
|
+
try {
|
|
329
|
+
const disk = JSON.parse(await readFile(stateFile, "utf8"));
|
|
330
|
+
if (disk && disk.version === STATE_VERSION && disk.tally && disk.cursors) {
|
|
331
|
+
state = disk;
|
|
332
|
+
} else if (disk && disk.version === 1) {
|
|
333
|
+
// Version 1 counted every API content block. Keep the heartbeat so a
|
|
334
|
+
// restart gap remains fenced, but rebuild the tally from transcripts.
|
|
335
|
+
state = { version: STATE_VERSION, cursors: {}, tally: {}, lastAlive: disk.lastAlive };
|
|
336
|
+
dirty = true;
|
|
337
|
+
}
|
|
338
|
+
} catch { /* first run: empty state */ }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function persist() {
|
|
342
|
+
if (!dirty) return;
|
|
343
|
+
dirty = false;
|
|
344
|
+
prune(state.tally, now());
|
|
345
|
+
const tmp = stateFile + ".tmp";
|
|
346
|
+
try {
|
|
347
|
+
await mkdir(dirname(stateFile), { recursive: true });
|
|
348
|
+
await writeFile(tmp, JSON.stringify(state), "utf8");
|
|
349
|
+
// Atomic replace, so a crash never leaves half a file — through the
|
|
350
|
+
// shared retry ladder, because a bare rename loses to a virus scanner or
|
|
351
|
+
// an indexer holding the target open on Windows (installer.mjs's #786).
|
|
352
|
+
await renameWithRetry(tmp, stateFile);
|
|
353
|
+
} catch { /* a failed persist just re-folds next time; cursors stay in memory */ }
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function foldFile(path, timeline) {
|
|
357
|
+
let st = null;
|
|
358
|
+
try { st = await stat(path); } catch { return; }
|
|
359
|
+
let offset = state.cursors[path] ?? 0;
|
|
360
|
+
// A shrunk file was replaced or rotated. Re-folding from 0 would double-count
|
|
361
|
+
// what is already in the tally (which is not keyed by file), so instead
|
|
362
|
+
// resync the cursor to the current end: the replaced content is lost, never
|
|
363
|
+
// counted twice. Transcripts append and do not shrink in normal use, so this
|
|
364
|
+
// is the rare, safe side to err on.
|
|
365
|
+
if (st.size < offset) { state.cursors[path] = st.size; dirty = true; return; }
|
|
366
|
+
if (st.size <= offset) return;
|
|
367
|
+
const fallback = cwdFromSlug(basename(dirname(path)));
|
|
368
|
+
let fh = null;
|
|
369
|
+
try {
|
|
370
|
+
fh = await open(path, "r");
|
|
371
|
+
const consumed = await foldAppended(fh, offset, st.size, (line) => foldLine(state.tally, line, timeline, fallback));
|
|
372
|
+
if (consumed > offset) { state.cursors[path] = consumed; dirty = true; }
|
|
373
|
+
} catch { /* unreadable this pass; cursor unchanged, tried again next pass */ }
|
|
374
|
+
finally { if (fh) await fh.close().catch(() => {}); }
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function pass() {
|
|
378
|
+
if (running) return; // one pass at a time; folding twice would double totals
|
|
379
|
+
running = true;
|
|
380
|
+
try {
|
|
381
|
+
await load();
|
|
382
|
+
const nowMs = now();
|
|
383
|
+
// The deck was down between the last heartbeat and this boot. Fence that
|
|
384
|
+
// stretch off so work timestamped inside it is unattributed rather than
|
|
385
|
+
// charged to whoever was active when the deck closed — the user may have
|
|
386
|
+
// switched accounts by hand while nothing was recording. Written before
|
|
387
|
+
// the seed so the order is stop → start.
|
|
388
|
+
if (state.lastAlive && nowMs - state.lastAlive > GAP_MS) {
|
|
389
|
+
await markGap(state.lastAlive + 1, swapLog);
|
|
390
|
+
}
|
|
391
|
+
await seedActive({ now, path: swapLog, root: storeRoot }); // anchor the current account, once, deduped
|
|
392
|
+
const timeline = await readSwapLog(swapLog);
|
|
393
|
+
const files = await listTranscripts(roots);
|
|
394
|
+
for (const path of files) await foldFile(path, timeline);
|
|
395
|
+
// Forget cursors for files that are gone, so the map cannot grow forever.
|
|
396
|
+
const alive = new Set(files);
|
|
397
|
+
for (const p of Object.keys(state.cursors)) if (!alive.has(p)) { delete state.cursors[p]; dirty = true; }
|
|
398
|
+
// Heartbeat: record that the deck was alive now, so a later boot can see
|
|
399
|
+
// how long it was down. Cheap when nothing else changed — only bumped
|
|
400
|
+
// (and so only persisted) once every HEARTBEAT_MS while idle.
|
|
401
|
+
if (dirty || !state.lastAlive || nowMs - state.lastAlive > HEARTBEAT_MS) {
|
|
402
|
+
state.lastAlive = nowMs;
|
|
403
|
+
dirty = true;
|
|
404
|
+
}
|
|
405
|
+
await persist();
|
|
406
|
+
} finally { running = false; }
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return {
|
|
410
|
+
async start() { await pass(); if (!timer) timer = setIv(() => { void pass(); }, intervalMs); if (timer.unref) timer.unref(); return this; },
|
|
411
|
+
stop() { if (timer) { clearIv(timer); timer = null; } },
|
|
412
|
+
/** Force one incremental pass now (used at boot and by tests). */
|
|
413
|
+
tick: pass,
|
|
414
|
+
/** Synchronous report from the in-memory tally + freshly-read swap log. */
|
|
415
|
+
async report(key, days) {
|
|
416
|
+
await load();
|
|
417
|
+
const timeline = await readSwapLog(swapLog);
|
|
418
|
+
return reportFrom(state.tally, timeline, key, days, now());
|
|
419
|
+
},
|
|
420
|
+
_state: () => state,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const NOSTALGIA_URL = "https://www.youtube.com/@bestofnostalgia";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
let cache = null;
|
|
9
|
+
let cacheAt = 0;
|
|
10
|
+
let cacheTtl = 0;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
|
|
13
|
+
function currentLiveVideo(html) {
|
|
14
|
+
const marker = '"liveBadgeText":"LIVE"';
|
|
15
|
+
const live = html.indexOf(marker);
|
|
16
|
+
if (live < 0) return null;
|
|
17
|
+
const start = Math.max(0, live - 2_000);
|
|
18
|
+
const window = html.slice(start, live + 2_000);
|
|
19
|
+
let best = null;
|
|
20
|
+
for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
|
|
21
|
+
const distance = Math.abs(start + match.index - live);
|
|
22
|
+
if (!best || distance < best.distance) best = { id: match[1], distance };
|
|
23
|
+
}
|
|
24
|
+
return best?.id ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function fetchBestOfNostalgia({ fetchImpl } = {}) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
30
|
+
if (inflight) return inflight;
|
|
31
|
+
const get = fetchImpl ?? fetch;
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await get(NOSTALGIA_URL, {
|
|
35
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
redirect: "follow",
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) return settle(null);
|
|
40
|
+
const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
|
|
41
|
+
return settle(video ? { id: "best-of-nostalgia", label: "Best of Nostalgia — Live", video } : null);
|
|
42
|
+
} catch {
|
|
43
|
+
return settle(null);
|
|
44
|
+
} finally {
|
|
45
|
+
inflight = null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return inflight;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function settle(value) {
|
|
52
|
+
cache = value;
|
|
53
|
+
cacheAt = Date.now();
|
|
54
|
+
cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function forgetBestOfNostalgia() {
|
|
59
|
+
cache = null;
|
|
60
|
+
cacheAt = 0;
|
|
61
|
+
cacheTtl = 0;
|
|
62
|
+
inflight = null;
|
|
63
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const CAFE_MUSIC_URL = "https://www.youtube.com/channel/UCJhjE7wbdYAae1G25m0tHAA";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
let cache = null;
|
|
9
|
+
let cacheAt = 0;
|
|
10
|
+
let cacheTtl = 0;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
|
|
13
|
+
function currentLiveVideo(html) {
|
|
14
|
+
const marker = '"liveBadgeText":"LIVE"';
|
|
15
|
+
const live = html.indexOf(marker);
|
|
16
|
+
if (live < 0) return null;
|
|
17
|
+
const start = Math.max(0, live - 2_000);
|
|
18
|
+
const window = html.slice(start, live + 2_000);
|
|
19
|
+
let best = null;
|
|
20
|
+
for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
|
|
21
|
+
const distance = Math.abs(start + match.index - live);
|
|
22
|
+
if (!best || distance < best.distance) best = { id: match[1], distance };
|
|
23
|
+
}
|
|
24
|
+
return best?.id ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function fetchCafeMusicBgm({ fetchImpl } = {}) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
30
|
+
if (inflight) return inflight;
|
|
31
|
+
const get = fetchImpl ?? fetch;
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await get(CAFE_MUSIC_URL, {
|
|
35
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
redirect: "follow",
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) return settle(null);
|
|
40
|
+
const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
|
|
41
|
+
return settle(video ? { id: "cafe-music-bgm", label: "Cafe Music BGM — Live", video } : null);
|
|
42
|
+
} catch {
|
|
43
|
+
return settle(null);
|
|
44
|
+
} finally {
|
|
45
|
+
inflight = null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return inflight;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function settle(value) {
|
|
52
|
+
cache = value;
|
|
53
|
+
cacheAt = Date.now();
|
|
54
|
+
cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function forgetCafeMusicBgm() {
|
|
59
|
+
cache = null;
|
|
60
|
+
cacheAt = 0;
|
|
61
|
+
cacheTtl = 0;
|
|
62
|
+
inflight = null;
|
|
63
|
+
}
|
|
@@ -566,6 +566,15 @@ async function runTick() {
|
|
|
566
566
|
// invalidating on those would throw away readings the deck paid a subprocess
|
|
567
567
|
// for, every interval, forever.
|
|
568
568
|
if (result.switched) forgetAccountScopedCaches();
|
|
569
|
+
// Record the move for the account-projects report. Only on a real switch, and
|
|
570
|
+
// best effort — the tick must not fail because a log line did not append. The
|
|
571
|
+
// slot is resolved from the store inside recordSwap, so cswap's `to` need not
|
|
572
|
+
// be threaded through here.
|
|
573
|
+
if (result.switched) {
|
|
574
|
+
import("./swap-log.mjs")
|
|
575
|
+
.then(({ recordSwap }) => recordSwap(result.to, "auto"))
|
|
576
|
+
.catch(() => {});
|
|
577
|
+
}
|
|
569
578
|
_lastTick = { at: Date.now(), ...result };
|
|
570
579
|
}
|
|
571
580
|
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const GOOD_LIFE_URL = "https://www.youtube.com/@TheGoodLiferadio";
|
|
2
|
+
const CACHE_MS = 600_000;
|
|
3
|
+
const MISS_CACHE_MS = 60_000;
|
|
4
|
+
const READ_LIMIT = 2_000_000;
|
|
5
|
+
const TIMEOUT_MS = 6_000;
|
|
6
|
+
const BROWSER_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36";
|
|
7
|
+
|
|
8
|
+
let cache = null;
|
|
9
|
+
let cacheAt = 0;
|
|
10
|
+
let cacheTtl = 0;
|
|
11
|
+
let inflight = null;
|
|
12
|
+
|
|
13
|
+
function currentLiveVideo(html) {
|
|
14
|
+
const marker = '"liveBadgeText":"LIVE"';
|
|
15
|
+
const live = html.indexOf(marker);
|
|
16
|
+
if (live < 0) return null;
|
|
17
|
+
const start = Math.max(0, live - 2_000);
|
|
18
|
+
const window = html.slice(start, live + 2_000);
|
|
19
|
+
let best = null;
|
|
20
|
+
for (const match of window.matchAll(/"videoId":"([\w-]{11})"/g)) {
|
|
21
|
+
const distance = Math.abs(start + match.index - live);
|
|
22
|
+
if (!best || distance < best.distance) best = { id: match[1], distance };
|
|
23
|
+
}
|
|
24
|
+
return best?.id ?? null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function fetchGoodLifeRadio({ fetchImpl } = {}) {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
if (cache && now - cacheAt < cacheTtl) return cache;
|
|
30
|
+
if (inflight) return inflight;
|
|
31
|
+
const get = fetchImpl ?? fetch;
|
|
32
|
+
inflight = (async () => {
|
|
33
|
+
try {
|
|
34
|
+
const response = await get(GOOD_LIFE_URL, {
|
|
35
|
+
headers: { "User-Agent": BROWSER_UA, "Accept-Language": "en-US,en;q=0.9" },
|
|
36
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
37
|
+
redirect: "follow",
|
|
38
|
+
});
|
|
39
|
+
if (!response.ok) return settle(null);
|
|
40
|
+
const video = currentLiveVideo((await response.text()).slice(0, READ_LIMIT));
|
|
41
|
+
return settle(video ? { id: "good-life-radio", label: "The Good Life Radio — Live", video } : null);
|
|
42
|
+
} catch {
|
|
43
|
+
return settle(null);
|
|
44
|
+
} finally {
|
|
45
|
+
inflight = null;
|
|
46
|
+
}
|
|
47
|
+
})();
|
|
48
|
+
return inflight;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function settle(value) {
|
|
52
|
+
cache = value;
|
|
53
|
+
cacheAt = Date.now();
|
|
54
|
+
cacheTtl = value ? CACHE_MS : MISS_CACHE_MS;
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function forgetGoodLifeRadio() {
|
|
59
|
+
cache = null;
|
|
60
|
+
cacheAt = 0;
|
|
61
|
+
cacheTtl = 0;
|
|
62
|
+
inflight = null;
|
|
63
|
+
}
|