sandoichi 0.1.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/LICENSE +21 -0
- package/README.md +25 -0
- package/index.mjs +54 -0
- package/package.json +27 -0
- package/src/active-session.mjs +136 -0
- package/src/cache-attribution.mjs +182 -0
- package/src/context-transform.mjs +492 -0
- package/src/core.mjs +258 -0
- package/src/history-budget.mjs +36 -0
- package/src/history-dedupe.mjs +98 -0
- package/src/history-shake.mjs +66 -0
- package/src/history-structure.mjs +28 -0
- package/src/hook-cli.mjs +118 -0
- package/src/mcp-server.mjs +45 -0
- package/src/metrics-cli.mjs +34 -0
- package/src/metrics.mjs +390 -0
- package/src/provider-ledger.mjs +138 -0
- package/src/provider-usage.mjs +224 -0
- package/src/proxy-metrics.mjs +34 -0
- package/src/proxy.mjs +212 -0
- package/src/routing.mjs +89 -0
- package/src/secret-redaction.mjs +21 -0
- package/src/semantic-compactor.mjs +218 -0
- package/src/statusline.mjs +100 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 yuzushi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# sandoichi
|
|
2
|
+
|
|
3
|
+
Cuts what Claude Code and Codex charge you to re-read their own output. Redacts secrets, caps oversized tool results, and, if you turn it on, trims request history before it's sent — all without calling an LLM itself.
|
|
4
|
+
|
|
5
|
+
This is the core library (`@sando/core` renamed `sandoichi` for npm). For the Claude Code plugin, Codex plugin, and provider proxy, see the [main repo](https://github.com/yuzushi-dev/Sando).
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install sandoichi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Requires Node.js `22.22.x`.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```js
|
|
18
|
+
import { optimizeToolOutput, createProviderProxy } from 'sandoichi';
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
See [index.mjs](./index.mjs) for the full list of exports (tool-output optimization, provider request transforms, semantic compaction, metrics, status line rendering).
|
|
22
|
+
|
|
23
|
+
## License
|
|
24
|
+
|
|
25
|
+
MIT
|
package/index.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createReceipt,
|
|
3
|
+
estimateTokens,
|
|
4
|
+
normalizeEvent,
|
|
5
|
+
normalizePolicy,
|
|
6
|
+
optimizeToolOutput,
|
|
7
|
+
} from './src/core.mjs';
|
|
8
|
+
export { detectProviderBody, listSemanticCandidates, transformProviderRequest } from './src/context-transform.mjs';
|
|
9
|
+
export {
|
|
10
|
+
buildSemanticPrompt,
|
|
11
|
+
createSemanticCompactor,
|
|
12
|
+
SEMANTIC_SUMMARY_SCHEMA,
|
|
13
|
+
validateSemanticSummary,
|
|
14
|
+
} from './src/semantic-compactor.mjs';
|
|
15
|
+
export { createProviderProxy } from './src/proxy.mjs';
|
|
16
|
+
export { shakeHistoricalResult } from './src/history-shake.mjs';
|
|
17
|
+
export {
|
|
18
|
+
attributeSession,
|
|
19
|
+
attributeTurn,
|
|
20
|
+
CACHE_MISS_CAUSES,
|
|
21
|
+
} from './src/cache-attribution.mjs';
|
|
22
|
+
export {
|
|
23
|
+
buildMetricsReport,
|
|
24
|
+
defaultMetricsPath,
|
|
25
|
+
formatMetricsReport,
|
|
26
|
+
readMetrics,
|
|
27
|
+
recordMetrics,
|
|
28
|
+
} from './src/metrics.mjs';
|
|
29
|
+
export {
|
|
30
|
+
aggregateProviderLedger,
|
|
31
|
+
createProviderLedgerEntry,
|
|
32
|
+
PROVIDER_LEDGER_SCHEMA,
|
|
33
|
+
PROVIDER_LEDGER_VERSION,
|
|
34
|
+
} from './src/provider-ledger.mjs';
|
|
35
|
+
export {
|
|
36
|
+
appendProviderUsage,
|
|
37
|
+
buildProviderUsageReport,
|
|
38
|
+
collectProviderUsage,
|
|
39
|
+
defaultProviderUsagePath,
|
|
40
|
+
parseClaudeTranscript,
|
|
41
|
+
parseCodexTranscript,
|
|
42
|
+
readProviderUsage,
|
|
43
|
+
PROVIDER_USAGE_SCHEMA,
|
|
44
|
+
PROVIDER_USAGE_VERSION,
|
|
45
|
+
} from './src/provider-usage.mjs';
|
|
46
|
+
export { planToolRoute, ROUTING_POLICY_VERSION } from './src/routing.mjs';
|
|
47
|
+
export { readStatusSnapshot, renderStatusLine, STATUSLINE_MAX_AGE_MS } from './src/statusline.mjs';
|
|
48
|
+
export {
|
|
49
|
+
activeSessionForPane,
|
|
50
|
+
currentTmuxPanePid,
|
|
51
|
+
defaultActiveSessionPath,
|
|
52
|
+
readActiveSessions,
|
|
53
|
+
recordActiveSession,
|
|
54
|
+
} from './src/active-session.mjs';
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sandoichi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cuts what Claude Code and Codex charge you to re-read their own output.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "yuzushi",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/yuzushi-dev/Sando.git",
|
|
10
|
+
"directory": "packages/sando"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/yuzushi-dev/Sando#readme",
|
|
13
|
+
"type": "module",
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=22.22.0 <23"
|
|
16
|
+
},
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./index.mjs"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"index.mjs",
|
|
22
|
+
"src"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"test": "node --test tests/*.test.mjs"
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
const SCHEMA = 'sando-active-session/v1';
|
|
8
|
+
const VERSION = 1;
|
|
9
|
+
const LOCK_WAIT_MS = 10;
|
|
10
|
+
const LOCK_ATTEMPTS = 250;
|
|
11
|
+
const STALE_LOCK_MS = 30_000;
|
|
12
|
+
|
|
13
|
+
export const ACTIVE_SESSION_SCHEMA = SCHEMA;
|
|
14
|
+
export const ACTIVE_SESSION_VERSION = VERSION;
|
|
15
|
+
|
|
16
|
+
function text(value) { return typeof value === 'string' && value.length > 0; }
|
|
17
|
+
function pane(value) { return text(value) && /^%\d+$/.test(value); }
|
|
18
|
+
function pid(value) {
|
|
19
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
20
|
+
return Number.isSafeInteger(number) && number > 0 ? number : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function emptyState() { return { schema: SCHEMA, version: VERSION, entries: [] }; }
|
|
24
|
+
|
|
25
|
+
function resolvePath(storagePath) {
|
|
26
|
+
if (typeof storagePath !== 'string' || !path.isAbsolute(storagePath)) {
|
|
27
|
+
throw new Error('active session path must be absolute');
|
|
28
|
+
}
|
|
29
|
+
return storagePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validateState(value) {
|
|
33
|
+
if (!value || typeof value !== 'object' || value.schema !== SCHEMA
|
|
34
|
+
|| value.version !== VERSION || !Array.isArray(value.entries)) {
|
|
35
|
+
throw new Error('active session state is invalid');
|
|
36
|
+
}
|
|
37
|
+
const panes = new Set();
|
|
38
|
+
for (const entry of value.entries) {
|
|
39
|
+
if (!pane(entry.paneId) || !text(entry.sessionId) || pid(entry.panePid) === null
|
|
40
|
+
|| typeof entry.updatedAt !== 'string' || Number.isNaN(Date.parse(entry.updatedAt))) {
|
|
41
|
+
throw new Error('active session entry is invalid');
|
|
42
|
+
}
|
|
43
|
+
if (panes.has(entry.paneId)) throw new Error('active session state contains duplicate panes');
|
|
44
|
+
panes.add(entry.paneId);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureDirectory(directory) {
|
|
50
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
51
|
+
const stat = fs.lstatSync(directory);
|
|
52
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error('active session directory is unsafe');
|
|
53
|
+
fs.chmodSync(directory, 0o700);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function waitForLock() {
|
|
57
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_WAIT_MS);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function withLock(lockPath, operation) {
|
|
61
|
+
let handle;
|
|
62
|
+
for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
|
|
63
|
+
try { handle = fs.openSync(lockPath, 'wx', 0o600); break; }
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
66
|
+
const stat = fs.statSync(lockPath, { throwIfNoEntry: false });
|
|
67
|
+
if (stat && Date.now() - stat.mtimeMs > STALE_LOCK_MS) fs.rmSync(lockPath, { force: true });
|
|
68
|
+
else waitForLock();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (handle === undefined) throw new Error('active session lock timeout');
|
|
72
|
+
try { return operation(); }
|
|
73
|
+
finally { fs.closeSync(handle); fs.rmSync(lockPath, { force: true }); }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function atomicWrite(filePath, value) {
|
|
77
|
+
const temporary = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${randomUUID()}.tmp`);
|
|
78
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
|
|
79
|
+
try { fs.renameSync(temporary, filePath); }
|
|
80
|
+
catch (error) { fs.rmSync(temporary, { force: true }); throw error; }
|
|
81
|
+
fs.chmodSync(filePath, 0o600);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function defaultActiveSessionPath(env = process.env) {
|
|
85
|
+
const configured = env.SANDO_ACTIVE_SESSION_PATH;
|
|
86
|
+
if (configured !== undefined) return resolvePath(configured);
|
|
87
|
+
const stateHome = env.XDG_STATE_HOME || path.join(os.homedir(), '.local', 'state');
|
|
88
|
+
if (!path.isAbsolute(stateHome)) throw new Error('state directory must be absolute');
|
|
89
|
+
return path.join(stateHome, 'sando', 'active-sessions.json');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function readActiveSessions(storagePath = defaultActiveSessionPath()) {
|
|
93
|
+
const filePath = resolvePath(storagePath);
|
|
94
|
+
if (!fs.existsSync(filePath)) return emptyState();
|
|
95
|
+
const stat = fs.lstatSync(filePath);
|
|
96
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('active session file is unsafe');
|
|
97
|
+
return validateState(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function currentTmuxPanePid(paneId, env = process.env) {
|
|
101
|
+
if (!pane(paneId)) return null;
|
|
102
|
+
const configured = pid(env.SANDO_CODEX_PANE_PID);
|
|
103
|
+
if (configured !== null) return configured;
|
|
104
|
+
try {
|
|
105
|
+
return pid(execFileSync('tmux', ['display-message', '-p', '-t', paneId, '#{pane_pid}'], {
|
|
106
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
107
|
+
}).trim());
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function recordActiveSession({ sessionId, paneId, panePid, storagePath = defaultActiveSessionPath(), now = new Date() } = {}) {
|
|
114
|
+
if (!text(sessionId) || !pane(paneId) || pid(panePid) === null) return false;
|
|
115
|
+
const filePath = resolvePath(storagePath);
|
|
116
|
+
ensureDirectory(path.dirname(filePath));
|
|
117
|
+
return withLock(`${filePath}.lock`, () => {
|
|
118
|
+
const state = readActiveSessions(filePath);
|
|
119
|
+
const entry = { paneId, panePid: pid(panePid), sessionId, updatedAt: new Date(now).toISOString() };
|
|
120
|
+
const index = state.entries.findIndex((candidate) => candidate.paneId === paneId);
|
|
121
|
+
if (index === -1) state.entries.push(entry);
|
|
122
|
+
else state.entries[index] = entry;
|
|
123
|
+
atomicWrite(filePath, state);
|
|
124
|
+
return true;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function activeSessionForPane({ paneId, panePid, storagePath = defaultActiveSessionPath() } = {}) {
|
|
129
|
+
if (!pane(paneId) || pid(panePid) === null) return undefined;
|
|
130
|
+
try {
|
|
131
|
+
const entry = readActiveSessions(storagePath).entries.find((candidate) => candidate.paneId === paneId);
|
|
132
|
+
return entry && entry.panePid === pid(panePid) ? entry : undefined;
|
|
133
|
+
} catch {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Why did the provider prompt cache miss on this turn?
|
|
3
|
+
*
|
|
4
|
+
* The provider ledger already records `cachedInputTokens` / `cacheWriteInputTokens`
|
|
5
|
+
* per turn, but a raw hit rate cannot say *why* a miss happened, and on its own it
|
|
6
|
+
* is the wrong instrument: a divergence behind the last cache breakpoint still costs
|
|
7
|
+
* a re-prefill while the hit is scored against the prefix that did match. So this
|
|
8
|
+
* module classifies each turn against the previous one and reports cache-write
|
|
9
|
+
* volume alongside hit/miss.
|
|
10
|
+
*
|
|
11
|
+
* Deterministic: pure functions over recorded counters and request shapes. No
|
|
12
|
+
* network, no LLM, no provider call.
|
|
13
|
+
*
|
|
14
|
+
* Anthropic invalidates hierarchically — `tools` -> `system` -> `messages` — and any
|
|
15
|
+
* change at one level invalidates that level and everything after it, so the causes
|
|
16
|
+
* below are ordered and the first match wins.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const CAUSES = [
|
|
20
|
+
'tools-changed',
|
|
21
|
+
'system-changed',
|
|
22
|
+
'history-rewritten',
|
|
23
|
+
'below-minimum',
|
|
24
|
+
'no-breakpoint',
|
|
25
|
+
'cold-start',
|
|
26
|
+
'ttl-expired',
|
|
27
|
+
'unexplained',
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const CACHE_MISS_CAUSES = Object.freeze([...CAUSES]);
|
|
31
|
+
|
|
32
|
+
// Anthropic's smallest cacheable prompt across current models. Below this the
|
|
33
|
+
// request is processed uncached with no error, so a "miss" is expected, not a defect.
|
|
34
|
+
const DEFAULT_MIN_CACHEABLE_TOKENS = 1024;
|
|
35
|
+
|
|
36
|
+
// Longest cache lifetime Anthropic offers (1h). A gap wider than this guarantees the
|
|
37
|
+
// entry is gone regardless of anything the client did.
|
|
38
|
+
const DEFAULT_MAX_TTL_MS = 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
function object(value) {
|
|
41
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function counter(value) {
|
|
45
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Stable digest of a JSON-serializable value. Order-sensitive by design: a reordered
|
|
49
|
+
* tool array is a different byte prefix to the provider even if the set is equal. */
|
|
50
|
+
export function shapeDigest(value) {
|
|
51
|
+
const json = JSON.stringify(value ?? null);
|
|
52
|
+
let hash = 0;
|
|
53
|
+
for (let index = 0; index < json.length; index += 1) {
|
|
54
|
+
hash = ((hash << 5) - hash + json.charCodeAt(index)) | 0;
|
|
55
|
+
}
|
|
56
|
+
return (hash >>> 0).toString(36);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Per-message digests, so a rewritten historical message is visible as a prefix
|
|
60
|
+
* divergence rather than only as a changed whole-history digest. */
|
|
61
|
+
export function messageDigests(messages) {
|
|
62
|
+
return Array.isArray(messages) ? messages.map((message) => shapeDigest(message)) : [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Index of the first message whose bytes differ from the previous turn's, or null
|
|
66
|
+
* when the previous turn is a prefix of this one (i.e. this turn only appended). */
|
|
67
|
+
export function firstDivergence(previousDigests, currentDigests) {
|
|
68
|
+
const bound = Math.min(previousDigests.length, currentDigests.length);
|
|
69
|
+
for (let index = 0; index < bound; index += 1) {
|
|
70
|
+
if (previousDigests[index] !== currentDigests[index]) return index;
|
|
71
|
+
}
|
|
72
|
+
return currentDigests.length < previousDigests.length ? bound : null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Does this request carry any cache_control breakpoint at all? */
|
|
76
|
+
export function hasBreakpoint(body) {
|
|
77
|
+
if (!object(body) && !Array.isArray(body)) return false;
|
|
78
|
+
if (Array.isArray(body)) return body.some((item) => hasBreakpoint(item));
|
|
79
|
+
for (const [key, value] of Object.entries(body)) {
|
|
80
|
+
if (key === 'cache_control' && value !== undefined) return true;
|
|
81
|
+
if (hasBreakpoint(value)) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Classify one turn.
|
|
88
|
+
*
|
|
89
|
+
* `current` / `previous` are `{ at, usage, tools, system, messages, body }`, where
|
|
90
|
+
* `usage` is `{ cachedInputTokens, cacheWriteInputTokens, inputTokens }` as recorded
|
|
91
|
+
* in the provider ledger. `previous` is null on the first turn of a session.
|
|
92
|
+
*/
|
|
93
|
+
export function attributeTurn({
|
|
94
|
+
current,
|
|
95
|
+
previous = null,
|
|
96
|
+
minCacheableTokens = DEFAULT_MIN_CACHEABLE_TOKENS,
|
|
97
|
+
maxTtlMs = DEFAULT_MAX_TTL_MS,
|
|
98
|
+
} = {}) {
|
|
99
|
+
if (!object(current)) throw new TypeError('current turn is required');
|
|
100
|
+
|
|
101
|
+
const usage = object(current.usage) ? current.usage : {};
|
|
102
|
+
const cacheReadTokens = counter(usage.cachedInputTokens);
|
|
103
|
+
const cacheWriteTokens = counter(usage.cacheWriteInputTokens);
|
|
104
|
+
const freshInputTokens = counter(usage.inputTokens);
|
|
105
|
+
const totalPromptTokens = cacheReadTokens + cacheWriteTokens + freshInputTokens;
|
|
106
|
+
const hit = cacheReadTokens > 0;
|
|
107
|
+
|
|
108
|
+
const currentDigests = messageDigests(current.messages);
|
|
109
|
+
const previousDigests = previous ? messageDigests(previous.messages) : [];
|
|
110
|
+
const divergedAt = previous ? firstDivergence(previousDigests, currentDigests) : null;
|
|
111
|
+
|
|
112
|
+
const detail = {
|
|
113
|
+
cacheReadTokens,
|
|
114
|
+
cacheWriteTokens,
|
|
115
|
+
freshInputTokens,
|
|
116
|
+
totalPromptTokens,
|
|
117
|
+
divergedAtMessage: divergedAt,
|
|
118
|
+
messagesBeforeDivergence: divergedAt ?? currentDigests.length,
|
|
119
|
+
breakpointPresent: hasBreakpoint(current.body ?? current),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (hit) return { hit: true, cause: null, ...detail };
|
|
123
|
+
|
|
124
|
+
// Ordered by Anthropic's invalidation hierarchy: the first true cause explains
|
|
125
|
+
// everything after it, so reporting a later one would be misleading.
|
|
126
|
+
let cause;
|
|
127
|
+
if (!previous) cause = 'cold-start';
|
|
128
|
+
else if (shapeDigest(current.tools) !== shapeDigest(previous.tools)) cause = 'tools-changed';
|
|
129
|
+
else if (shapeDigest(current.system) !== shapeDigest(previous.system)) cause = 'system-changed';
|
|
130
|
+
else if (divergedAt !== null) cause = 'history-rewritten';
|
|
131
|
+
else if (!detail.breakpointPresent) cause = 'no-breakpoint';
|
|
132
|
+
else if (totalPromptTokens > 0 && totalPromptTokens < minCacheableTokens) cause = 'below-minimum';
|
|
133
|
+
else if (elapsedMs(previous.at, current.at) > maxTtlMs) cause = 'ttl-expired';
|
|
134
|
+
else cause = 'unexplained';
|
|
135
|
+
|
|
136
|
+
return { hit: false, cause, ...detail };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function elapsedMs(from, to) {
|
|
140
|
+
const start = Date.parse(from ?? '');
|
|
141
|
+
const end = Date.parse(to ?? '');
|
|
142
|
+
return Number.isFinite(start) && Number.isFinite(end) ? end - start : 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Classify a whole session and summarize it.
|
|
147
|
+
*
|
|
148
|
+
* Reports cache-write volume as a first-class figure, not just hit rate: a turn can
|
|
149
|
+
* be scored a "hit" against a short matched prefix while re-writing most of the
|
|
150
|
+
* conversation, and that cost is invisible to hit rate alone.
|
|
151
|
+
*/
|
|
152
|
+
export function attributeSession(turns, options = {}) {
|
|
153
|
+
if (!Array.isArray(turns)) throw new TypeError('turns must be an array');
|
|
154
|
+
|
|
155
|
+
const results = turns.map((turn, index) =>
|
|
156
|
+
attributeTurn({ ...options, current: turn, previous: index === 0 ? null : turns[index - 1] }));
|
|
157
|
+
|
|
158
|
+
const misses = results.filter((result) => !result.hit);
|
|
159
|
+
const causes = Object.fromEntries(CAUSES.map((cause) => [cause, 0]));
|
|
160
|
+
for (const miss of misses) causes[miss.cause] += 1;
|
|
161
|
+
|
|
162
|
+
const sum = (key) => results.reduce((total, result) => total + result[key], 0);
|
|
163
|
+
const cacheReadTokens = sum('cacheReadTokens');
|
|
164
|
+
const cacheWriteTokens = sum('cacheWriteTokens');
|
|
165
|
+
const freshInputTokens = sum('freshInputTokens');
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
schema: 'sando-cache-attribution/v1',
|
|
169
|
+
turns: results.length,
|
|
170
|
+
hits: results.length - misses.length,
|
|
171
|
+
misses: misses.length,
|
|
172
|
+
hitRate: results.length === 0 ? null : (results.length - misses.length) / results.length,
|
|
173
|
+
causes,
|
|
174
|
+
cacheReadTokens,
|
|
175
|
+
cacheWriteTokens,
|
|
176
|
+
freshInputTokens,
|
|
177
|
+
// Cache writes bill at 1.25x (5m) or 2x (1h) of base input while reads bill at
|
|
178
|
+
// 0.1x, so write volume relative to reads is the ratio that actually tracks spend.
|
|
179
|
+
writeToReadRatio: cacheReadTokens === 0 ? null : cacheWriteTokens / cacheReadTokens,
|
|
180
|
+
perTurn: results,
|
|
181
|
+
};
|
|
182
|
+
}
|