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/CHANGELOG.md +16 -0
- package/README.md +100 -3
- package/bin/session-steward-cli.mjs +4 -0
- package/bin/session-steward-mcp.mjs +65 -0
- package/dist/assets/index-C94A1O5c.js +9 -0
- package/dist/assets/index-CXq8Tw8T.css +2 -0
- package/dist/index.html +2 -2
- package/lib/cli.mjs +102 -2
- package/lib/mcp.mjs +607 -0
- package/lib/providers/claude-code/events.mjs +17 -1
- package/lib/providers/claude-code/index.mjs +2 -0
- package/lib/providers/claude-code/store.mjs +4 -0
- package/lib/providers/claude-code/tokens.mjs +174 -0
- package/lib/providers/codex/events.mjs +17 -1
- package/lib/providers/codex/index.mjs +2 -0
- package/lib/providers/codex/store.mjs +14 -2
- package/lib/providers/codex/tokens.mjs +317 -0
- package/lib/server.mjs +30 -0
- package/lib/session-events.mjs +4 -0
- package/lib/session-token-cache.mjs +76 -0
- package/lib/session-tokens.mjs +73 -0
- package/package.json +9 -2
- package/dist/assets/index-BOACkzUI.js +0 -9
- package/dist/assets/index-DFAWGcgb.css +0 -2
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);
|
package/lib/session-events.mjs
CHANGED
|
@@ -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.
|
|
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
|
}
|