session-steward 0.7.0 → 0.9.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/lib/server.mjs CHANGED
@@ -865,6 +865,9 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
865
865
  id,
866
866
  limit: getSessionEventLimit(requestUrl.searchParams.get("limit")),
867
867
  signal: controller.signal,
868
+ // The scan is already streaming the whole transcript, so the count
869
+ // comes back with it rather than costing a second pass over it.
870
+ tokens: true,
868
871
  });
869
872
 
870
873
  if (controller.signal.aborted) return;
@@ -878,6 +881,33 @@ export async function startLocalServer({ claudeHome, codexHome, configDirectory,
878
881
  return;
879
882
  }
880
883
 
884
+ if (request.method === "GET" && requestUrl.pathname === "/api/session-tokens") {
885
+ const controller = new AbortController();
886
+ const abortRead = () => controller.abort();
887
+ request.once("aborted", abortRead);
888
+ response.once("close", () => {
889
+ if (!response.writableEnded) abortRead();
890
+ });
891
+ const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
892
+ const provider = getProvider(providerId);
893
+ const id = getSessionId(requestUrl.searchParams.get("id"));
894
+ const tokens = await provider.readSessionTokens({
895
+ ...providerOptions(providerId, settings.getHome(providerId)),
896
+ id,
897
+ signal: controller.signal,
898
+ });
899
+
900
+ if (controller.signal.aborted) return;
901
+
902
+ if (!tokens) {
903
+ sendJson(response, 404, { error: "Session not found." });
904
+ return;
905
+ }
906
+
907
+ sendJson(response, 200, { tokens });
908
+ return;
909
+ }
910
+
881
911
  if (request.method === "GET" && requestUrl.pathname.startsWith("/api/sessions/")) {
882
912
  const providerId = resolveProviderId(requestUrl.searchParams.get("provider"));
883
913
  const provider = getProvider(providerId);
@@ -318,6 +318,7 @@ export function createSessionEventsResult({
318
318
  header,
319
319
  reason = null,
320
320
  summary,
321
+ tokens = null,
321
322
  window = {},
322
323
  } = {}) {
323
324
  const normalizedCoverage = createSessionEventCoverage(coverage);
@@ -370,6 +371,9 @@ export function createSessionEventsResult({
370
371
  reason,
371
372
  composition: normalizedComposition,
372
373
  summary: normalizedSummary,
374
+ // Counted during this same pass, so the panel's header does not have to
375
+ // read the transcript a second time to fill in two fields.
376
+ tokens,
373
377
  window: {
374
378
  complete,
375
379
  end,
@@ -0,0 +1,76 @@
1
+ import fs from "node:fs/promises";
2
+
3
+ // A token count is a pure function of a transcript's bytes, so an unchanged
4
+ // file never needs reading twice — which matters most for a fork, whose parent
5
+ // is a whole extra file scanned only to find where the replay ends.
6
+ //
7
+ // Identity is dev+inode alongside size and mtime, mirroring
8
+ // `transcriptActivityCache` (`lib/providers/claude-code/store.mjs:39`): a path
9
+ // that has been replaced is a different file, not a stale entry. An active
10
+ // session's mtime moves on every write, so it re-reads rather than serving a
11
+ // count that has stopped growing.
12
+ // Two limits, because entries are not the same size. A session summary is about
13
+ // a kilobyte whatever the transcript weighed; a fork parent's signature
14
+ // recording is 16 bytes a turn, so 512 of those is a number with no ceiling.
15
+ // Counting entries bounds the bookkeeping, counting bytes bounds the memory.
16
+ const MAX_ENTRIES = 512;
17
+ const MAX_BYTES = 32 * 1024 * 1024;
18
+ const cache = new Map();
19
+ let cachedBytes = 0;
20
+
21
+ export async function readFileStamp(filePath) {
22
+ if (!filePath) return null;
23
+ try {
24
+ const stats = await fs.stat(filePath);
25
+ return `${stats.dev ?? ""}:${stats.ino ?? ""}:${stats.size}:${stats.mtimeMs}`;
26
+ } catch {
27
+ return null;
28
+ }
29
+ }
30
+
31
+ export function readCachedTokens(kind, filePath, stamp) {
32
+ if (!stamp) return undefined;
33
+ const entry = cache.get(`${kind} ${filePath}`);
34
+ return entry?.stamp === stamp ? entry.value : undefined;
35
+ }
36
+
37
+ function evict(key) {
38
+ const entry = cache.get(key);
39
+ if (!entry) return;
40
+ cachedBytes -= entry.bytes;
41
+ cache.delete(key);
42
+ }
43
+
44
+ export function writeCachedTokens(kind, filePath, stamp, value, bytes = 1024) {
45
+ if (!stamp) return value;
46
+ // An entry that cannot fit inside the budget would evict everything else and
47
+ // then sit there alone. Reading it again is cheaper than that.
48
+ if (bytes > MAX_BYTES) return value;
49
+
50
+ const key = `${kind} ${filePath}`;
51
+ evict(key);
52
+ cache.set(key, { bytes, stamp, value });
53
+ cachedBytes += bytes;
54
+
55
+ while (cache.size > MAX_ENTRIES || cachedBytes > MAX_BYTES) {
56
+ const oldest = cache.keys().next().value;
57
+ if (oldest === key) break;
58
+ evict(oldest);
59
+ }
60
+
61
+ return value;
62
+ }
63
+
64
+ // Two numbers a turn, in arrays V8 stores unboxed.
65
+ export function signatureBytes(signatures) {
66
+ return signatures ? signatures.runningTotals.length * 16 : 0;
67
+ }
68
+
69
+ export function sessionTokenCacheStats() {
70
+ return { bytes: cachedBytes, entries: cache.size, maxBytes: MAX_BYTES, maxEntries: MAX_ENTRIES };
71
+ }
72
+
73
+ export function clearSessionTokenCache() {
74
+ cache.clear();
75
+ cachedBytes = 0;
76
+ }
@@ -0,0 +1,73 @@
1
+ // The shape both providers report in, and the derived figures the UI renders.
2
+ //
3
+ // Codex counts cached tokens inside its input figure and Anthropic counts them
4
+ // beside it, so the providers cannot share a formula. They share this shape
5
+ // instead: each collector resolves its own cache math and hands back the same
6
+ // four buckets, which always sum to the total.
7
+ export const TOKEN_SEGMENT_KEYS = Object.freeze(["freshInput", "cachedInput", "cacheWrites", "output"]);
8
+
9
+ export function createTokenTotals() {
10
+ return { cachedInput: 0, cacheWrites: 0, freshInput: 0, output: 0, reasoning: 0, total: 0 };
11
+ }
12
+
13
+ export function addTokenTotals(target, usage) {
14
+ target.cachedInput += usage.cachedInput;
15
+ target.cacheWrites += usage.cacheWrites;
16
+ target.freshInput += usage.freshInput;
17
+ target.output += usage.output;
18
+ target.reasoning += usage.reasoning;
19
+ target.total += usage.total;
20
+ return target;
21
+ }
22
+
23
+ function share(part, whole) {
24
+ return whole > 0 ? part / whole : 0;
25
+ }
26
+
27
+ function inputTokens(totals) {
28
+ return totals.freshInput + totals.cachedInput + totals.cacheWrites;
29
+ }
30
+
31
+ // A fork's rollout replays its parent's turns before its own. The bar describes
32
+ // what this session actually spent, so the inherited half is reported alongside
33
+ // it rather than folded into it.
34
+ export function summarizeSessionTokens(collected, { fork = null, forkParentMissing = false } = {}) {
35
+ if (!collected || collected.available !== true) {
36
+ return { available: false, reason: collected?.complete === false ? "incomplete" : "absent" };
37
+ }
38
+
39
+ const totals = fork ? fork.own : collected.totals;
40
+ const byModel = fork ? fork.ownByModel : collected.byModel;
41
+ const warnings = [];
42
+ if (collected.cacheWriteUnderflow) warnings.push("cache-write-underflow");
43
+ if (collected.complete === false) warnings.push("incomplete-scan");
44
+ // A fork whose parent is gone cannot be split, so the total still carries the
45
+ // inherited turns. Say so rather than presenting it as this session's spend.
46
+ if (forkParentMissing) warnings.push("fork-parent-missing");
47
+
48
+ return {
49
+ available: true,
50
+ byModel: (byModel ?? []).map(({ model, totals: modelTotals }) => ({
51
+ model,
52
+ share: share(modelTotals.total, totals.total),
53
+ tokens: modelTotals.total,
54
+ })),
55
+ // "N% of input was served from cache" — output is not part of the question.
56
+ cacheHitRate: inputTokens(totals) > 0 ? share(totals.cachedInput, inputTokens(totals)) : null,
57
+ compactions: collected.compactions ?? 0,
58
+ inherited: fork ? { tokens: fork.inherited.total, turns: fork.inheritedTurns } : null,
59
+ // Reasoning is part of output, so it is reported against output rather than
60
+ // as a segment of its own.
61
+ reasoning: totals.reasoning > 0
62
+ ? { share: share(totals.reasoning, totals.output), tokens: totals.reasoning }
63
+ : null,
64
+ segments: TOKEN_SEGMENT_KEYS.map((key) => ({
65
+ key,
66
+ share: share(totals[key], totals.total),
67
+ tokens: totals[key],
68
+ })),
69
+ total: totals.total,
70
+ totals,
71
+ warnings,
72
+ };
73
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "session-steward",
3
- "version": "0.7.0",
3
+ "version": "0.9.0",
4
4
  "description": "Codex and Claude Code session manager - browse, back up, and delete old sessions. Local browser UI + CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Mallik Cheripally",
7
7
  "type": "module",
8
8
  "bin": {
9
9
  "session-steward": "bin/session-steward.mjs",
10
- "session-steward-cli": "bin/session-steward-cli.mjs"
10
+ "session-steward-cli": "bin/session-steward-cli.mjs",
11
+ "session-steward-mcp": "bin/session-steward-mcp.mjs"
11
12
  },
12
13
  "repository": {
13
14
  "type": "git",
@@ -59,6 +60,7 @@
59
60
  "benchmark:overview": "node --expose-gc test/benchmarks/codex-overview.mjs",
60
61
  "benchmark:scale": "node --expose-gc test/benchmarks/codex-list.mjs",
61
62
  "benchmark:size": "node --expose-gc test/benchmarks/codex-size.mjs",
63
+ "benchmark:tokens": "node --expose-gc test/benchmarks/session-tokens.mjs",
62
64
  "benchmark:transcripts": "node --expose-gc test/benchmarks/codex-transcripts.mjs",
63
65
  "benchmark:versioned-stores": "node --expose-gc test/benchmarks/codex-versioned-stores.mjs",
64
66
  "prepack": "npm run build",
@@ -69,6 +71,7 @@
69
71
  "test": "node --test test/*.test.mjs test/providers/*.test.mjs"
70
72
  },
71
73
  "devDependencies": {
74
+ "@modelcontextprotocol/client": "^2.0.0",
72
75
  "@tailwindcss/vite": "^4.3.3",
73
76
  "@vitejs/plugin-react": "^6.0.5",
74
77
  "lucide-react": "^1.28.0",
@@ -76,5 +79,9 @@
76
79
  "react-dom": "^19.2.8",
77
80
  "tailwindcss": "^4.3.3",
78
81
  "vite": "^8.2.0"
82
+ },
83
+ "dependencies": {
84
+ "@modelcontextprotocol/server": "^2.0.0",
85
+ "zod": "^4.4.3"
79
86
  }
80
87
  }