@alexeiled/claude-router 0.2.2 → 0.4.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.
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "router",
3
3
  "displayName": "Router",
4
- "version": "0.2.2",
5
- "description": "Local gateway that selects a model and an effort level for each user turn with a TypeSafe Jev Choice.",
4
+ "version": "0.4.0",
5
+ "description": "Auto-picks the right Claude model for each turn small models for quick edits, mid-tier for code, frontier for hard problems. Uses Jev to classify each request.",
6
6
  "author": { "name": "Alexei Ledenev", "url": "https://github.com/alexei-led" },
7
7
  "repository": "https://github.com/alexei-led/claude-router",
8
8
  "license": "MIT",
@@ -10,8 +10,8 @@
10
10
  "userConfig": {
11
11
  "typesafe_api_key": {
12
12
  "type": "string",
13
- "title": "TypeSafe API key",
14
- "description": "Jev key for the routing decision. Stored in the Keychain. Leave empty to run on the baseline tier.",
13
+ "title": "Jev API key (typesafe.ai)",
14
+ "description": "API key from typesafe.ai. Jev reads each prompt and picks the right model tier. Without a key every turn runs on Sonnet.",
15
15
  "sensitive": true
16
16
  }
17
17
  }
package/README.md CHANGED
@@ -1,10 +1,15 @@
1
1
  # claude-router
2
2
 
3
- A Claude Code plugin that selects a model and an effort level for each user turn.
3
+ [![CI](https://github.com/alexei-led/claude-router/actions/workflows/ci.yml/badge.svg)](https://github.com/alexei-led/claude-router/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/@alexeiled/claude-router)](https://www.npmjs.com/package/@alexeiled/claude-router)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Node.js ≥22](https://img.shields.io/node/v/@alexeiled/claude-router.svg)](https://nodejs.org/)
7
+
8
+ A Claude Code plugin that auto-picks the right model and effort for each turn.
4
9
 
5
10
  A local gateway on `127.0.0.1` receives each request from Claude Code. For a
6
- new user turn, the gateway asks TypeSafe Jev which tier the turn needs. Then
7
- the gateway changes `model`, `effort` and `thinking` in the request and sends
11
+ new user turn, the gateway asks Jev (TypeSafe) which tier the turn needs. Then
12
+ the gateway rewrites `model`, `effort` and `thinking` in the request and sends
8
13
  it to Anthropic. All other data goes through unchanged. The gateway has no
9
14
  runtime dependencies and needs Node 22 or later.
10
15
 
@@ -21,14 +26,14 @@ Claude Code --model router ──▶ gateway 127.0.0.1:43170 ──▶ api.
21
26
  responses go through unchanged; the gateway reads `usage` (context size, cache TTL)
22
27
  ```
23
28
 
24
- The tiers are `micro` (haiku), `low` (sonnet, the baseline), `medium` (opus at
25
- high effort) and `high` (opus at xhigh effort).
29
+ The tiers are `micro` (Haiku), `low` (Sonnet, the baseline), `medium` (Opus at
30
+ high effort) and `high` (Opus at xhigh effort). The exact model IDs are in
31
+ `~/.claude/router.json` and default to the current generation of each family.
26
32
 
27
- A tool continuation is a request whose last message is a `tool_result`. It
28
- keeps the route of the turn, and the gateway does not ask Jev. Side requests,
29
- for example session titles, get the baseline tier. A request for any other
30
- model goes through unchanged. This is how `/router:<tier>` pins and subagents
31
- with their own `model` work.
33
+ A tool continuation keeps the route of its turn the gateway does not ask Jev.
34
+ Side requests, for example session titles, get the baseline tier. A request for
35
+ any other model goes through unchanged. This is how `/router:<tier>` pins and
36
+ subagents with their own `model` work.
32
37
 
33
38
  Module dependencies point in one direction: `gateway.mjs` (HTTP) →
34
39
  `router.mjs` (orchestration) → `facts`, `jev`, `policy` → `cost`, `rewrite`,
@@ -44,16 +49,37 @@ Only `store` writes files. The Jev transport is injected.
44
49
  claude plugin install router@alexei-led-claude-router
45
50
  ```
46
51
 
47
- 2. When Claude Code asks, enter the TypeSafe API key. The key goes to the
48
- macOS Keychain.
52
+ 2. When Claude Code asks, enter the Jev API key from [typesafe.ai](https://typesafe.ai).
53
+ The key goes to the macOS Keychain and persists across updates.
49
54
  3. In Claude Code, run `/router:setup`. It writes `model`,
50
55
  `env.ANTHROPIC_BASE_URL` and the `/model` picker row to
51
56
  `~/.claude/settings.json`, and offers a status line segment.
52
57
  4. Restart Claude Code. `/router:status` shows the routes and the last turn.
53
58
 
54
- The `SessionStart` hook of the plugin starts the gateway when the port does not
55
- answer. A claude.ai login continues to work: the gateway sends the
56
- authorization header and the OAuth value of `anthropic-beta` unchanged.
59
+ One gateway serves all Claude Code sessions on the machine. The plugin hooks
60
+ start it at session start and before each prompt when it does not answer, so a
61
+ stopped or crashed gateway comes back on the next prompt. After two hours
62
+ without requests it exits by itself.
63
+
64
+ A claude.ai login continues to work: the gateway forwards the authorization
65
+ header and the `anthropic-beta` OAuth value unchanged.
66
+
67
+ ## Update
68
+
69
+ ```sh
70
+ claude plugin marketplace update alexei-led-claude-router
71
+ claude plugin update router@alexei-led-claude-router
72
+ ```
73
+
74
+ Then restart Claude Code or run `/reload-plugins`. The next prompt replaces the
75
+ running gateway with the new version: the old gateway releases the port at once
76
+ and finishes the responses it is streaming. A session that still runs an older
77
+ plugin never replaces a newer gateway. The Jev API key stays in the macOS
78
+ Keychain. Run `/router:setup` once after an update: the status line command
79
+ path contains the plugin version.
80
+
81
+ Updating from 0.3.0 or earlier: these gateways cannot hand over, so stop the
82
+ old one once with `pkill -f scripts/gateway.mjs`.
57
83
 
58
84
  ## Documentation
59
85
 
@@ -11,7 +11,7 @@
11
11
 
12
12
  The gateway ignores project files. To use another file, set
13
13
  `ROUTER_CONFIG=/path/to/file.json`. The gateway reads the file at start. After
14
- an edit, stop the gateway with `pkill -f scripts/gateway.mjs`. The next session
14
+ an edit, stop the gateway with `pkill -f scripts/gateway.mjs`. The next prompt
15
15
  starts it again.
16
16
 
17
17
  ## Claude Code settings
@@ -23,9 +23,10 @@ starts it again.
23
23
  "model": "router",
24
24
  "env": {
25
25
  "ANTHROPIC_BASE_URL": "http://127.0.0.1:43170",
26
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS": "1000000",
26
27
  "ANTHROPIC_CUSTOM_MODEL_OPTION": "router",
27
28
  "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME": "Router (auto)",
28
- "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION": "Picks Opus 5.5 / Sonnet 4.6 / Haiku 4.5 and the effort for each turn"
29
+ "ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION": "Picks Opus 5.5 / Sonnet 5 / Haiku 4.5 and the effort for each turn"
29
30
  }
30
31
  }
31
32
  ```
@@ -33,6 +34,13 @@ starts it again.
33
34
  The three `ANTHROPIC_CUSTOM_MODEL_OPTION*` keys add a `Router (auto)` row to
34
35
  the `/model` picker.
35
36
 
37
+ Claude Code does not know the model `router`, so it assumes a 200K window.
38
+ `CLAUDE_CODE_MAX_CONTEXT_TOKENS` declares the real window: the largest
39
+ `contextWindow` of the routed models, 1M by default. The gateway sends a turn
40
+ only to a model whose window holds the context with room to spare (80%). A
41
+ large session skips Haiku (200K) and goes to Sonnet or Opus. The
42
+ `/router:status` reason for such a turn is `context-fit`.
43
+
36
44
  If you agree, it also wraps the status line command:
37
45
 
38
46
  ```json
@@ -45,7 +53,7 @@ If you agree, it also wraps the status line command:
45
53
  ```
46
54
 
47
55
  The wrapper runs the command after it, then adds one line for a routed
48
- session, for example `router ▸ opus-5-5 · xhigh (high)`. Without a command
56
+ session, for example `router ▸ opus-5-5 · xhigh`. Without a command
49
57
  after it, it prints only that line. The path contains the plugin version, so
50
58
  run `/router:setup` again after a plugin update.
51
59
 
@@ -69,7 +77,8 @@ path. Nested objects merge.
69
77
  "port": 43170,
70
78
  "alias": "router",
71
79
  "baselineTier": "low",
72
- "auxiliaryTier": "low"
80
+ "auxiliaryTier": "low",
81
+ "idleShutdownMs": 7200000
73
82
  },
74
83
  "routes": {
75
84
  "high": { "model": "opus", "effort": "xhigh" },
@@ -79,12 +88,12 @@ path. Nested objects merge.
79
88
  },
80
89
  "models": {
81
90
  "sonnet": {
82
- "id": "claude-sonnet-4-6",
83
- "input": 3,
84
- "cacheRead": 0.3,
91
+ "id": "claude-sonnet-5",
92
+ "input": 2,
93
+ "cacheRead": 0.2,
85
94
  "contextWindow": 1000000,
86
95
  "billing": "plan",
87
- "efforts": ["low", "medium", "high", "max"]
96
+ "efforts": ["low", "medium", "high", "xhigh", "max"]
88
97
  }
89
98
  },
90
99
  "policy": {
@@ -114,11 +123,11 @@ frontmatter of `skills/<tier>/SKILL.md`. A test makes sure that they agree.
114
123
 
115
124
  ### models
116
125
 
117
- | Alias | ID | Input | Cache Read | Window | Billing | Efforts |
118
- | -------- | ------------------- | ----- | ---------- | ------ | ------- | ------- |
119
- | `opus` | `claude-opus-5-5` | $4 | $0.2 | 1M | plan | all |
120
- | `sonnet` | `claude-sonnet-4-6` | $3 | $0.3 | 1M | plan | low–max |
121
- | `haiku` | `claude-haiku-4-5` | $1 | $0.1 | 200k | plan | none |
126
+ | Alias | ID | Input | Cache Read | Window | Billing | Efforts |
127
+ | -------- | ------------------ | ----- | ---------- | ------ | ------- | ------------- |
128
+ | `opus` | `claude-opus-5-5` | $4 | $0.2 | 1M | plan | all |
129
+ | `sonnet` | `claude-sonnet-5` | $2 | $0.2 | 1M | plan | low–xhigh–max |
130
+ | `haiku` | `claude-haiku-4-5` | $1 | $0.1 | 200k | plan | none |
122
131
 
123
132
  `id` is the model id that the gateway sends to Anthropic. `input` and
124
133
  `cacheRead` are list prices in USD per million tokens. `contextWindow` is the
@@ -134,6 +143,7 @@ effort and thinking from the request.
134
143
  | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------- |
135
144
  | `gateway.baselineTier` | The tier when nothing else decides: Jev abstains, Jev fails, or the session is new. |
136
145
  | `gateway.auxiliaryTier` | The tier for side requests, for example session titles. |
146
+ | `gateway.idleShutdownMs` | The gateway exits after this long without requests, when no turn waits for a tool result. Two hours by default; `0` keeps it running. |
137
147
  | `upgradeVotes` | The number of consecutive votes above the current tier before an upgrade of one tier. |
138
148
  | `upgradeBase`, `upgradeSlope`, `upgradePivotUsd` | The required probability mass: `base + slope * tax / (tax + pivot)`. The `tax` is the extra input cost to read the context on the new model. |
139
149
  | `jumpConfidence` | The mass that lets a jump of two tiers skip the vote delay. |
@@ -150,3 +160,8 @@ effort and thinking from the request.
150
160
  | `ROUTER_CONFIG` | The path of the configuration file. |
151
161
  | `ROUTER_FORCE_TIER` | `micro`, `low`, `medium` or `high`. Skips Jev and the policy and always routes to that tier. For tests. |
152
162
  | `CLAUDE_PLUGIN_DATA` | Set by Claude Code for hooks. The directory holds `sessions/`, `decisions.jsonl` and `gateway.log`. |
163
+
164
+ The gateway keeps the directory bounded. Above 20 MB, `decisions.jsonl` moves
165
+ to `decisions.jsonl.1` (checked every hour), and `gateway.log` moves to
166
+ `gateway.log.1` when a gateway starts. One previous generation is kept. Session
167
+ files unused for 30 days are removed.
package/docs/design.md CHANGED
@@ -61,7 +61,7 @@ keeps its route. The baseline is a configuration value.
61
61
  | ------ | ------ | ------- | -------------------- |
62
62
  | high | opus | xhigh | claude-opus-5-5 |
63
63
  | medium | opus | high | claude-opus-5-5 |
64
- | low | sonnet | as sent | claude-sonnet-4-6 |
64
+ | low | sonnet | as sent | claude-sonnet-5 |
65
65
  | micro | haiku | none | claude-haiku-4-5 |
66
66
 
67
67
  The gateway lowers the effort to a level that the model family accepts. Sonnet
@@ -79,6 +79,46 @@ account.
79
79
  `format` is a side request.
80
80
  - Any other `model`: unchanged. This covers `/router:<tier>` pins,
81
81
  `/model` changes and subagents with their own model.
82
+ - A resent request (the same history length and the same last message): the
83
+ route of the turn, without a Jev call or a vote. Claude Code resends after a
84
+ 429, a 529 or a dropped connection.
85
+ - A side endpoint with the alias, such as `/v1/messages/count_tokens`: the
86
+ model of the session's last route. Only `POST /v1/messages` is a turn.
87
+
88
+ ## Failure handling
89
+
90
+ One gateway serves every Claude Code session on the machine, so a failure in one
91
+ request must not reach the others.
92
+
93
+ - Anthropic errors (429, 529, 5xx) pass through unchanged, with `Retry-After`.
94
+ Claude Code owns retries and backoff; a second retry layer in the gateway
95
+ would multiply attempts and cannot replay a stream that has started.
96
+ - The response is relayed with `pipeline()`. An upstream reset destroys the
97
+ client response, so Claude Code sees a reset and retries at once. A client
98
+ that leaves (Esc) destroys the upstream request, so the model stops
99
+ generating an answer that nobody reads.
100
+ - Disk errors while the gateway saves session memory or the decision log are
101
+ logged. The routing decision stands.
102
+ - Jev: one retry on a network error or a transient status, after
103
+ `Retry-After` when it fits the 1.5 s budget. After three failures in a row,
104
+ new turns skip Jev for a minute, then try once.
105
+ - The daemon logs a stray exception instead of exiting. On `SIGTERM` it
106
+ releases the port at once and finishes open streams for up to 10 minutes.
107
+ A second daemon on a busy port exits quietly.
108
+ - The `SessionStart` and `UserPromptSubmit` hooks start the gateway when the
109
+ port does not answer, and replace a gateway older than the plugin. They never
110
+ replace a newer one.
111
+ - The gateway exits after `gateway.idleShutdownMs` (two hours) without
112
+ requests. Claude Code holds no connection open between requests, so the
113
+ gateway cannot tell a closed session from an idle one; time is the signal.
114
+ Two cases keep it running: a request in flight, and a turn whose last
115
+ response asked for a tool (`stop_reason: tool_use`), such as an unanswered
116
+ permission prompt. The answer to that prompt reaches the gateway without a
117
+ new prompt, so without the hook that would start it again. A session that
118
+ died mid-turn stops counting after a day. Two hours outlast `/loop` wakeups
119
+ and Monitor waits, which also arrive without a prompt.
120
+ - The gateway refuses requests with a non-loopback `Host` (DNS rebinding) or a
121
+ web `Origin` (cross-site requests from a browser).
82
122
 
83
123
  ## Cache and cost inputs
84
124
 
@@ -141,7 +181,7 @@ Agreed with Codex on 2026-09-22. The thresholds are start values.
141
181
  ## Test results (2026-09-22, Max plan, Claude Code 2.1.278)
142
182
 
143
183
  - Easy prompt: Jev gave `micro` at confidence 1. It was the first vote, so
144
- `low` served (`claude-sonnet-4-6`, 103,799 context tokens on the first
184
+ `low` served (`claude-sonnet-5`, 103,799 context tokens on the first
145
185
  request).
146
186
  - Hard prompt: Jev gave `high` at 0.66. A cold Fable write cost $1.30 against
147
187
  the cap of $0.50 at that time, so `medium` served (`claude-opus-5`). Four
@@ -4,8 +4,11 @@
4
4
 
5
5
  After the install steps in the README, run `claude` as usual. Claude Code shows
6
6
  `Router (auto)` as the model. The transcript records the model that answered
7
- each message. The first session starts the gateway. The gateway continues to
8
- run after the session ends.
7
+ each message. The first session starts the gateway, and all sessions share it.
8
+ The gateway continues to run after a session ends. After two hours without
9
+ requests it exits, unless a turn waits for a tool result, for example a
10
+ permission prompt that you have not answered. The next prompt starts it again
11
+ (`gateway.idleShutdownMs` in [configuration](configuration.md)).
9
12
 
10
13
  To try the gateway in one session without a change to the configuration:
11
14
 
@@ -34,7 +37,12 @@ ANTHROPIC_BASE_URL=http://127.0.0.1:43170 claude --plugin-dir . --model router
34
37
  prompt for these credits, so the cap is the only guard. No default model
35
38
  bills credits; this applies once you add one in `router.json`.
36
39
  - When Jev fails or times out, or when there is no key, the baseline tier
37
- serves the turn.
40
+ serves the turn. After three failures in a row, new turns skip Jev for one
41
+ minute and then try it once, so an outage costs one slow turn a minute, not
42
+ a second on every turn. `/router:status` shows the pause.
43
+ - When Claude Code sends the same request again (after a 429, a 529 or a
44
+ dropped connection), the gateway keeps the route of that turn. A retry is
45
+ not a new vote and makes no Jev call.
38
46
  - Jev receives the prompt and the last six turns of text. Tool results are not
39
47
  sent. No other data leaves the machine, except the usual Anthropic request.
40
48
 
@@ -61,10 +69,10 @@ reason of the last turn in this session.
61
69
  The status line wrapper from `/router:setup` adds one line to your status line
62
70
  while the session uses the router:
63
71
 
64
- - `router ▸ opus-5-5 · xhigh (high)`: the model, the effort and the tier of the
65
- last turn.
72
+ - `router ▸ opus-5-5 · xhigh`: the model and the effort of the last turn.
66
73
  - `router: no turn yet`: the session has no routed turn.
67
- - `router: gateway down`: the gateway does not answer.
74
+ - `router: gateway off, the next prompt starts it`: the gateway stopped after
75
+ idle time, or it crashed. Either way the next prompt starts it.
68
76
 
69
77
  The line updates when Claude Code redraws the status line, after each message.
70
78
 
@@ -94,10 +102,16 @@ assistant message in a Claude Code transcript.
94
102
  `curl http://127.0.0.1:43170/v1/models` lists the alias.
95
103
  - If Claude Code reports that it does not use the gateway, run
96
104
  `/router:setup` and restart Claude Code.
97
- - If each turn runs on Sonnet, make sure that the key is set. Read the
98
- `router:` lines in `gateway.log` next to `decisions.jsonl`.
105
+ - If each turn runs on the default tier, make sure that the key is set, and
106
+ read `/router:status`: it shows when Jev is paused after failures. The
107
+ timestamped `router:` lines in `gateway.log` next to `decisions.jsonl` give
108
+ the reason, for example `jev http 401` for a rejected key.
99
109
  - If the effort is not what you set, read the `efforts` list of the model. The
100
- gateway lowers the effort to a level that the model accepts. Sonnet 4.6 has
101
- no `xhigh`. Haiku has no effort and no thinking.
102
- - To stop the gateway, run `pkill -f scripts/gateway.mjs`. The next session
103
- starts it again.
110
+ gateway lowers the effort to a level that the model accepts. Haiku has no
111
+ effort and no thinking.
112
+ - To stop the gateway, run `pkill -f scripts/gateway.mjs`. It releases the port
113
+ at once and finishes the responses it is streaming. The next prompt starts it
114
+ again.
115
+ - Rate limits and overload errors from Anthropic (429, 529) reach Claude Code
116
+ unchanged. Claude Code waits and retries; the gateway does not add a second
117
+ layer of retries.
package/hooks/hooks.json CHANGED
@@ -11,6 +11,18 @@
11
11
  }
12
12
  ]
13
13
  }
14
+ ],
15
+ "UserPromptSubmit": [
16
+ {
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "node",
21
+ "args": ["${CLAUDE_PLUGIN_ROOT}/scripts/ensure-gateway.mjs", "--quiet"],
22
+ "timeout": 10
23
+ }
24
+ ]
25
+ }
14
26
  ]
15
27
  }
16
28
  }
package/lib/config.mjs CHANGED
@@ -4,7 +4,10 @@ export const TIERS = ['micro', 'low', 'medium', 'high'];
4
4
  export const EFFORTS = ['low', 'medium', 'high', 'xhigh', 'max'];
5
5
 
6
6
  export const DEFAULTS = {
7
- gateway: { port: 43170, alias: 'router', baselineTier: 'low', auxiliaryTier: 'low' },
7
+ // `idleShutdownMs`: the gateway exits after this long without requests while no turn waits for a tool result;
8
+ // the hooks start it again before the next prompt. Two hours outlast /loop wakeups and Monitor waits (up to one
9
+ // hour), which reach the gateway without a prompt. 0 keeps it running.
10
+ gateway: { port: 43170, alias: 'router', baselineTier: 'low', auxiliaryTier: 'low', idleShutdownMs: 7_200_000 },
8
11
  // `high` and `medium` share one model and differ by effort: Opus 5.5 at xhigh is the strongest
9
12
  // setting this router can ask for, and the tier ladder stays four wide for the policy.
10
13
  routes: {
@@ -26,12 +29,12 @@ export const DEFAULTS = {
26
29
  efforts: EFFORTS,
27
30
  },
28
31
  sonnet: {
29
- id: 'claude-sonnet-4-6',
30
- input: 3,
31
- cacheRead: 0.3,
32
+ id: 'claude-sonnet-5',
33
+ input: 2,
34
+ cacheRead: 0.2,
32
35
  contextWindow: 1_000_000,
33
36
  billing: 'plan',
34
- efforts: ['low', 'medium', 'high', 'max'],
37
+ efforts: EFFORTS,
35
38
  },
36
39
  haiku: { id: 'claude-haiku-4-5', input: 1, cacheRead: 0.1, contextWindow: 200_000, billing: 'plan', efforts: [] },
37
40
  },
@@ -113,6 +116,8 @@ function validate(config) {
113
116
  if (!Number.isInteger(config.gateway.port) || config.gateway.port < 1 || config.gateway.port > 65535)
114
117
  throw new Error('gateway.port must be a port number');
115
118
  if (typeof config.gateway.alias !== 'string' || !config.gateway.alias) throw new Error('gateway.alias is required');
119
+ if (!(Number.isFinite(config.gateway.idleShutdownMs) && config.gateway.idleShutdownMs >= 0))
120
+ throw new Error('gateway.idleShutdownMs must be a non-negative number (0 keeps the gateway running)');
116
121
  const p = config.policy;
117
122
  for (const field of ['upgradeBase', 'jumpConfidence', 'downgradeMass', 'continuationMass']) {
118
123
  if (!(p[field] >= 0 && p[field] <= 1)) throw new Error(`policy.${field} must be between 0 and 1`);
package/lib/facts.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  // Facts the policy needs, derived from one Messages request body plus what the router remembers
2
2
  // about the conversation. Pure: body and memory in, plain object out.
3
+ import { createHash } from 'node:crypto';
3
4
 
4
5
  const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash']);
5
6
  const FAILURE_WINDOW = 40;
@@ -28,6 +29,8 @@ export function factsFromRequest(body, memory, { recentTurns, maxTextChars }) {
28
29
  turns: turns.slice(-recentTurns),
29
30
  prompt: last?.role === 'user' ? textOf(lastBlocks) : '',
30
31
  continuation: last?.role === 'user' && lastBlocks.some((b) => b.type === 'tool_result'),
32
+ // The same history length and the same last message: Claude Code resent the request (429, 529, a dropped stream).
33
+ turnKey: last?.role === 'user' ? `${messages.length}:${hash(JSON.stringify(last.content))}` : null,
31
34
  failure: repeatedFailure(errors, edits, messages.length - 1),
32
35
  lastRoute: memory.lastRoute,
33
36
  lastRequest: memory.lastRequest,
@@ -52,6 +55,10 @@ function signatureOf(block) {
52
55
  return textOf(blocks(block.content)).toLowerCase().replace(/\d+/g, '#').replace(/\s+/g, ' ').slice(0, 120);
53
56
  }
54
57
 
58
+ function hash(text) {
59
+ return createHash('sha256').update(text).digest('hex').slice(0, 16);
60
+ }
61
+
55
62
  function blocks(content) {
56
63
  if (typeof content === 'string') return [{ type: 'text', text: content }];
57
64
  return Array.isArray(content) ? content : [];