tinker-agent 1.0.65

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.
Files changed (110) hide show
  1. package/README.md +173 -0
  2. package/package.json +78 -0
  3. package/patches/markdansi@0.3.2.patch +37 -0
  4. package/src/agent/context-builder.ts +43 -0
  5. package/src/agent/context-meter.ts +310 -0
  6. package/src/agent/loop.ts +525 -0
  7. package/src/agent/runtime-session.ts +1212 -0
  8. package/src/agent/session-ledger.ts +828 -0
  9. package/src/agent/turn-cancellation.ts +44 -0
  10. package/src/agent/types.ts +77 -0
  11. package/src/cli/config.ts +283 -0
  12. package/src/cli/index.ts +29 -0
  13. package/src/cli/model-profiles.ts +289 -0
  14. package/src/cli/run-runner.ts +107 -0
  15. package/src/cli/tui-runner.tsx +290 -0
  16. package/src/context/compiled-context-hash.ts +138 -0
  17. package/src/context/compiled-context-validator.ts +209 -0
  18. package/src/context/context-manager.ts +362 -0
  19. package/src/context/context-policy.ts +8 -0
  20. package/src/context/context-protocol-validator.ts +463 -0
  21. package/src/context/context-revision-compiler.ts +281 -0
  22. package/src/context/context-revision.ts +111 -0
  23. package/src/context/context-source.ts +30 -0
  24. package/src/context/context-swap-renderer.ts +272 -0
  25. package/src/context/protocol-frame.ts +240 -0
  26. package/src/context/swap-planner.ts +725 -0
  27. package/src/events/append-private-file.ts +16 -0
  28. package/src/events/bash-result-detail.ts +70 -0
  29. package/src/events/composite-event-sink.ts +82 -0
  30. package/src/events/event-sink.ts +16 -0
  31. package/src/events/jsonl-event-log.ts +13 -0
  32. package/src/events/observation-text-log.ts +195 -0
  33. package/src/events/stdout-event-printer.ts +396 -0
  34. package/src/events/types.ts +263 -0
  35. package/src/ids/runtime-id.ts +68 -0
  36. package/src/ids/uuid-v7.ts +5 -0
  37. package/src/instructions/project-instructions.ts +242 -0
  38. package/src/mcp/mcp-config.ts +144 -0
  39. package/src/mcp/mcp-manager.ts +216 -0
  40. package/src/mcp/mcp-tool-executor.ts +178 -0
  41. package/src/model/committed-prefix-auditor.ts +68 -0
  42. package/src/model/fake-model-client.ts +280 -0
  43. package/src/model/model-client.ts +64 -0
  44. package/src/model/model-context-profile.ts +134 -0
  45. package/src/model/model-request-preflight.ts +120 -0
  46. package/src/model/openai-chat-mapping.ts +444 -0
  47. package/src/model/openai-chat-model-client.ts +190 -0
  48. package/src/model/prompt-prefix-hash.ts +47 -0
  49. package/src/model/token-estimator.ts +148 -0
  50. package/src/observation/observation-builder.ts +481 -0
  51. package/src/session/resume-projection.ts +616 -0
  52. package/src/session/session-catalog.ts +270 -0
  53. package/src/session/session-errors.ts +121 -0
  54. package/src/session/session-history-reader.ts +535 -0
  55. package/src/session/session-lock.ts +291 -0
  56. package/src/session/session-schema.ts +741 -0
  57. package/src/session/session-store.ts +3067 -0
  58. package/src/session/sqlite-session-ledger.ts +153 -0
  59. package/src/tools/bash-task.ts +617 -0
  60. package/src/tools/bash.ts +450 -0
  61. package/src/tools/cwd-state.ts +22 -0
  62. package/src/tools/edit.ts +428 -0
  63. package/src/tools/file-diff.ts +116 -0
  64. package/src/tools/glob.ts +202 -0
  65. package/src/tools/grep.ts +550 -0
  66. package/src/tools/hash.ts +9 -0
  67. package/src/tools/path-safety.ts +33 -0
  68. package/src/tools/read.ts +319 -0
  69. package/src/tools/recall.ts +400 -0
  70. package/src/tools/registry.ts +213 -0
  71. package/src/tools/ripgrep.ts +220 -0
  72. package/src/tools/task-list.ts +59 -0
  73. package/src/tools/task-output-snapshot.ts +47 -0
  74. package/src/tools/task-output-tool.ts +62 -0
  75. package/src/tools/task-output.ts +159 -0
  76. package/src/tools/task-stop.ts +59 -0
  77. package/src/tools/task-tool-args.ts +29 -0
  78. package/src/tools/types.ts +330 -0
  79. package/src/tools/web-fetch/backend.ts +27 -0
  80. package/src/tools/web-fetch/browser-backend.ts +126 -0
  81. package/src/tools/web-fetch/exa-backend.ts +172 -0
  82. package/src/tools/web-fetch/index.ts +298 -0
  83. package/src/tools/web-fetch/local-backend.ts +267 -0
  84. package/src/tools/web-fetch/refiner.ts +78 -0
  85. package/src/tools/web-fetch/route.ts +95 -0
  86. package/src/tools/web-search.ts +300 -0
  87. package/src/tools/write.ts +244 -0
  88. package/src/tui/app.tsx +497 -0
  89. package/src/tui/components/assistant-markdown.tsx +47 -0
  90. package/src/tui/components/background-tasks.tsx +92 -0
  91. package/src/tui/components/bash-result-view.tsx +47 -0
  92. package/src/tui/components/context-status.tsx +127 -0
  93. package/src/tui/components/diff-view.tsx +151 -0
  94. package/src/tui/components/file-viewer.tsx +212 -0
  95. package/src/tui/components/footer.tsx +60 -0
  96. package/src/tui/components/header.tsx +21 -0
  97. package/src/tui/components/model-picker.tsx +142 -0
  98. package/src/tui/components/prompt-input.tsx +432 -0
  99. package/src/tui/components/resume-session-picker.tsx +273 -0
  100. package/src/tui/components/timeline.tsx +121 -0
  101. package/src/tui/context-format.ts +24 -0
  102. package/src/tui/event-store.ts +865 -0
  103. package/src/tui/git-branch.ts +23 -0
  104. package/src/tui/line-editor.ts +157 -0
  105. package/src/tui/prompt-history.ts +94 -0
  106. package/src/tui/slash-commands.ts +126 -0
  107. package/src/tui/tui-projection-policy.ts +35 -0
  108. package/src/tui/tui-projection-store.ts +123 -0
  109. package/src/tui/tui-session-controller.ts +170 -0
  110. package/src/tui/view-file.ts +122 -0
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # Tinker
2
+
3
+ **Tinker** is a personal coding agent — an interactive TUI (Terminal User Interface) and one-shot CLI tool that drives an LLM in an agent loop with file, search, shell, and MCP tools to read and modify a local workspace.
4
+
5
+ Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://github.com/vadimdemedes/ink) (React for CLIs).
6
+
7
+ ## Features
8
+
9
+ - **Interactive TUI**: Full terminal user interface with session management, prompt history, and slash commands.
10
+ - **One-shot CLI**: Run a single prompt non-interactively with `tinker run "prompt"`.
11
+ - **Built-in tools**:
12
+ - `Glob` / `Grep` — Find and search files by pattern or content
13
+ - `Read` / `Write` / `Edit` — File I/O with content hashing and concurrent-modification protection
14
+ - `Bash` — Run shell commands (foreground and background) with per-task working directories
15
+ - `TaskList` / `TaskOutput` / `TaskStop` — Manage long-running background shell tasks
16
+ - `WebSearch` — Search the web via Exa API
17
+ - `WebFetch` — Fetch and refine web page content (local, browser, or Exa backend)
18
+ - `Recall` — Search or retrieve model-visible history from the current session
19
+ - **MCP integration**: Connect external [Model Context Protocol](https://modelcontextprotocol.io) servers — their tools are dynamically registered as `mcp__<server>__<tool>`.
20
+ - **Session persistence**: Sessions are persisted via SQLite, supporting session resume, history recall, and a catalog to browse and switch between sessions.
21
+ - **Observation system**: Tool execution results are formatted into structured text that the model sees, with separate raw results for event logs and TUI display.
22
+ - **Turn cancellation**: Users can cancel an ongoing turn safely, with protocol-safe synthetic tool messages.
23
+ - **Context metering**: Budget-aware context management with protocol validation before sending requests to the model.
24
+ - **Deterministic context compaction**: Idle sessions can swap eligible historical tool output into Recall-addressable placeholders without calling the model.
25
+ - **Choice of models**: Supports any OpenAI-compatible API (defaults to DeepSeek). Configurable via environment variables.
26
+
27
+ ## Quick Start
28
+
29
+ ```bash
30
+ # Clone and install
31
+ git clone <repo>
32
+ cd tinker
33
+ bun install
34
+
35
+ # Start the interactive TUI
36
+ bun run tinker
37
+
38
+ # Run a one-shot prompt
39
+ bun run tinker run "explain the project structure"
40
+ ```
41
+
42
+ ### Slash Commands
43
+
44
+ - `/view <path>` — Open a readable UTF-8 text file in a full-window viewer. Relative
45
+ paths must remain inside the workspace; absolute paths may point outside it. Use
46
+ the keyboard or mouse wheel to scroll and press `Esc` to close the viewer.
47
+ - `/status` — Show session and context details.
48
+ - `/compact` — Deterministically compact eligible historical tool output while the session is idle.
49
+ - `/model [profile-name]` — Choose a model profile for a new session.
50
+ - `/resume [session-id]` — Choose or directly resume a stored session.
51
+ - `/session delete <session-id> --confirm` — Delete a stored session.
52
+ - `/quit` — Exit the TUI.
53
+
54
+ ## Configuration
55
+
56
+ Tinker is configured via environment variables:
57
+
58
+ | Variable | Default | Description |
59
+ |---|---|---|
60
+ | `TINKER_MODEL` | — | Required model name when `TINKER_MODELS` is not set |
61
+ | `TINKER_BASE_URL` | — | Required API base URL |
62
+ | `TINKER_API_KEY` | — | Required API key |
63
+ | `TINKER_MODELS` | — | Path to a multi-model profiles JSON file (see below) |
64
+ | `TINKER_WORKSPACE` | `process.cwd()` | Workspace root |
65
+ | `TINKER_MAX_ITERATIONS` | `512` | Max agent loop iterations per turn |
66
+ | `EXA_API_KEY` | — | Enables WebSearch tool |
67
+ | `TINKER_MCP_CONFIG` | — | Path to MCP server config JSON |
68
+ | `TINKER_EVENT_LOG` | `$TINKER_DIR/events.jsonl` | Event log path |
69
+ | `TINKER_SESSION_DIR` | `$TINKER_DIR/sessions` | Session storage directory |
70
+ | `TINKER_INCLUDE_REASONING` | `false` | Include model reasoning in output |
71
+ | `TINKER_TASK_STOP_GRACE_MS` | `5000` | Grace period before SIGKILL |
72
+ | `TINKER_MCP_TIMEOUT_MS` | `30000` | MCP tool timeout |
73
+ | `TINKER_CONTEXT_BUDGET_TOKENS` | `128000` | Context budget in tokens |
74
+
75
+ ### Multi-Model Profiles
76
+
77
+ Set `TINKER_MODELS` in `.env` to point to a JSON file with multiple named model
78
+ profiles. When set, the profile's `default` field selects the startup model, and
79
+ the `/model` slash command (available in new sessions before any turns) lets you
80
+ switch to another profile. If `TINKER_MODELS` is not set, Tinker falls back to
81
+ the individual `TINKER_*` environment variables. A configured profiles file must
82
+ exist and be valid; Tinker does not silently fall back when it cannot be loaded.
83
+
84
+ ```json
85
+ {
86
+ "default": "deepseek",
87
+ "profiles": {
88
+ "deepseek": {
89
+ "model": "deepseek-chat",
90
+ "apiBase": "https://api.deepseek.com/v1",
91
+ "apiKey": "sk-xxx",
92
+ "contextWindowTokens": 128000,
93
+ "maxSupportedOutputTokens": 8192
94
+ },
95
+ "gpt-4o": {
96
+ "model": "gpt-4o",
97
+ "apiBase": "https://api.openai.com/v1",
98
+ "apiKey": "sk-yyy",
99
+ "contextWindowTokens": 128000,
100
+ "maxSupportedOutputTokens": 16384,
101
+ "includeReasoningContent": true
102
+ }
103
+ }
104
+ }
105
+ ```
106
+
107
+ You can also start TUI with a specific profile:
108
+
109
+ ```bash
110
+ bun run tinker --profile gpt-4o
111
+ ```
112
+
113
+ Switching models creates a new session. The previous session is preserved and
114
+ can be resumed with `/resume`. Each session records its profile name, and resume
115
+ reopens the session with that profile. Resume fails clearly if the profile is no
116
+ longer present or its runtime contract has changed. Older sessions without a
117
+ stored profile name can resume only when their model name uniquely matches one
118
+ configured profile.
119
+
120
+ `Read` has a fixed 262144-byte (256 KiB) content limit per call. A successful
121
+ call always returns the complete requested line range. Use `offset` and `limit`
122
+ to page through larger files; oversized requests fail instead of returning
123
+ truncated content.
124
+
125
+ ## Commands
126
+
127
+ ```bash
128
+ bun install # Install dependencies
129
+ bun run tinker # Start the interactive TUI
130
+ bun run tinker run "..." # Run a one-shot CLI prompt
131
+ bun test # Run test suite
132
+ bun run typecheck # TypeScript type checking (tsc --noEmit)
133
+ bun run lint # ESLint (zero warnings required)
134
+ bun run format # Biome code formatting
135
+ bun run check # Full check: typecheck + format + lint + test
136
+ ```
137
+
138
+ ## Project Structure
139
+
140
+ ```
141
+ tinker/
142
+ ├── src/
143
+ │ ├── cli/ # Entry points (tui, run), config
144
+ │ ├── agent/ # Agent loop, session ledger, turn cancellation, context metering
145
+ │ ├── tools/ # Tool executors (bash, glob, grep, read, write, edit, recall, etc.)
146
+ │ ├── model/ # Model clients (OpenAI-compatible, fake), chat mapping, preflight
147
+ │ ├── mcp/ # MCP server management, tool executor adapter
148
+ │ ├── observation/ # Tool result → model-visible text
149
+ │ ├── session/ # SQLite session store, catalog, history reader, resume
150
+ │ ├── events/ # Event sinks, JSONL log, stdout printer
151
+ │ ├── tui/ # Ink/React UI components, projection store, session controller
152
+ │ ├── context/ # Context protocol validation, protocol frame construction
153
+ │ └── ids/ # Runtime ID generation (UUID v7)
154
+ ├── docs/ # Design notes and planning documents
155
+ ├── .tinker/ # Runtime data (sessions, bash tasks, events)
156
+ └── package.json
157
+ ```
158
+
159
+ ## Design Philosophy
160
+
161
+ - **Fast-fail**: Validate assumptions early and return clear errors close to the source. Structured failures allow the model to correct and retry.
162
+ - **Model sees only text**: Tool execution results are rendered into readable text for the model. Raw result data with extra detail is kept for event logs and the TUI.
163
+ - **Protocol safety**: All tool calls produce protocol-safe messages — even cancellations, fatal errors, or interruptions generate well-formed tool messages so the agent loop can continue.
164
+ - **Session durability**: Every turn, iteration, and tool call is committed to the SQLite ledger before the model is called, enabling reliable resume and history recall.
165
+
166
+ ## Requirements
167
+
168
+ - [Bun](https://bun.sh) (developed with Bun 1.x)
169
+ - A compatible LLM API (defaults to DeepSeek; any OpenAI-compatible API works)
170
+
171
+ ## License
172
+
173
+ MIT
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "tinker-agent",
3
+ "version": "1.0.65",
4
+ "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
+ "license": "UNLICENSED",
6
+ "type": "module",
7
+ "bin": {
8
+ "tinker": "src/cli/index.ts",
9
+ "tinker-agent": "src/cli/index.ts"
10
+ },
11
+ "files": [
12
+ "src/agent",
13
+ "src/cli",
14
+ "src/context",
15
+ "src/events",
16
+ "src/ids",
17
+ "src/instructions",
18
+ "src/mcp",
19
+ "src/model",
20
+ "src/observation",
21
+ "src/session",
22
+ "src/tools",
23
+ "src/tui",
24
+ "patches",
25
+ "README.md"
26
+ ],
27
+ "engines": {
28
+ "bun": ">=1.3.0"
29
+ },
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "scripts": {
34
+ "bench:long-session": "bun scripts/bench-long-session-memory.ts",
35
+ "bench:recall": "bun scripts/bench-recall.ts",
36
+ "bench:smoke": "bun scripts/bench-smoke.ts",
37
+ "tinker": "bun src/cli/index.ts",
38
+ "check": "bun run typecheck && bun run format:check && bun run lint && bun test && bun run bench:smoke",
39
+ "format": "biome format --write .",
40
+ "format:check": "biome format .",
41
+ "lint": "eslint \"src/**/*.{ts,tsx}\" \"scripts/**/*.ts\" --max-warnings=0",
42
+ "lint:fix": "eslint \"src/**/*.{ts,tsx}\" \"scripts/**/*.ts\" --fix",
43
+ "test": "bun test",
44
+ "typecheck": "tsc --noEmit"
45
+ },
46
+ "dependencies": {
47
+ "@assistant-ui/react-ink": "^0.0.31",
48
+ "@assistant-ui/react-ink-markdown": "^0.0.30",
49
+ "@inkjs/ui": "^2.0.0",
50
+ "@modelcontextprotocol/sdk": "^1.29.0",
51
+ "@mozilla/readability": "^0.6.0",
52
+ "diff": "^9.0.0",
53
+ "glob": "^13.0.6",
54
+ "ink": "^7.1.0",
55
+ "linkedom": "^0.18.13",
56
+ "openai": "^6.45.0",
57
+ "react": "^19.2.7",
58
+ "shiki": "^4.3.1",
59
+ "turndown": "^7.2.4",
60
+ "uuid": "^14.0.1"
61
+ },
62
+ "devDependencies": {
63
+ "@biomejs/biome": "^2.5.2",
64
+ "@eslint/js": "^10.0.1",
65
+ "@types/bun": "^1.3.14",
66
+ "@types/react": "^19.2.17",
67
+ "@types/turndown": "^5.0.6",
68
+ "eslint": "^10.6.0",
69
+ "eslint-plugin-react-hooks": "^7.1.1",
70
+ "globals": "^17.7.0",
71
+ "ink-testing-library": "^4.0.0",
72
+ "typescript": "^6.0.3",
73
+ "typescript-eslint": "^8.62.1"
74
+ },
75
+ "patchedDependencies": {
76
+ "markdansi@0.3.2": "patches/markdansi@0.3.2.patch"
77
+ }
78
+ }
@@ -0,0 +1,37 @@
1
+ diff --git a/dist/render.js b/dist/render.js
2
+ index 5de3a5d5c1e0c8bfe8dec992ca3d57987f2ba6ee..53237b86315dc56fdb2e6295aea698ee033350ce 100644
3
+ --- a/dist/render.js
4
+ +++ b/dist/render.js
5
+ @@ -572,7 +572,7 @@ function renderTable(node, ctx) {
6
+ const content = ctx.options.tableTruncate
7
+ ? truncateCell(cell, target, ctx.options.tableEllipsis)
8
+ : cell;
9
+ - const wrapped = wrapText(content, ctx.options.wrap ? target : Number.MAX_SAFE_INTEGER, ctx.options.wrap);
10
+ + const wrapped = wrapText(content, ctx.options.wrap ? target : Number.MAX_SAFE_INTEGER, ctx.options.wrap).flatMap((line) => hardWrapTableCellLine(line, target, ctx.options.wrap));
11
+ return wrapped.map((l) => {
12
+ const aligned = padCell(l, target, aligns[idx] ?? "left");
13
+ const padded = `${padStr}${aligned}${padStr}`;
14
+ @@ -627,6 +627,23 @@ function sliceCellContent(text, width) {
15
+ }
16
+ return sliced;
17
+ }
18
+ +function hardWrapTableCellLine(text, width, wrap) {
19
+ + if (!wrap || width <= 0 || visibleWidth(text) <= width)
20
+ + return [text];
21
+ + const parts = [];
22
+ + let remaining = text;
23
+ + while (visibleWidth(remaining) > width) {
24
+ + const part = sliceCellContent(remaining, width);
25
+ + const consumedWidth = visibleWidth(part);
26
+ + if (consumedWidth <= 0)
27
+ + break;
28
+ + parts.push(part);
29
+ + remaining = sliceAnsi(remaining, consumedWidth);
30
+ + }
31
+ + if (remaining !== "")
32
+ + parts.push(remaining);
33
+ + return parts.length ? parts : [text];
34
+ +}
35
+ function wrapCodeLine(text, width) {
36
+ // Hard-wrap code even without spaces while keeping ANSI-safe width accounting.
37
+ if (width <= 0)
@@ -0,0 +1,43 @@
1
+ import type { ToolDefinition } from "../tools/types";
2
+ import type {
3
+ BuiltContextRequest,
4
+ CompiledRevisionContext,
5
+ StoredContextRevisionV5,
6
+ SwapOverride,
7
+ } from "../context/context-revision";
8
+ import type { ProtocolContextView } from "../context/protocol-frame";
9
+
10
+ export class ContextBuilder {
11
+ build(input: {
12
+ canonical: ProtocolContextView;
13
+ revision: StoredContextRevisionV5;
14
+ activeOverrides: readonly SwapOverride[];
15
+ compiled: CompiledRevisionContext;
16
+ tools: readonly ToolDefinition[];
17
+ candidateUserPrompt?: string;
18
+ }): BuiltContextRequest {
19
+ if (input.canonical.sessionId !== input.compiled.sessionId) {
20
+ throw new Error(
21
+ "Compiled context and canonical history belong to different sessions.",
22
+ );
23
+ }
24
+ const messages = input.compiled.entries.map((entry) => entry.message);
25
+ if (input.candidateUserPrompt !== undefined) {
26
+ if (input.candidateUserPrompt.trim() === "") {
27
+ throw new Error("Cannot build a candidate context with an empty prompt.");
28
+ }
29
+ messages.push({ role: "user", content: input.candidateUserPrompt });
30
+ }
31
+ return Object.freeze({
32
+ canonical: input.canonical,
33
+ revision: input.revision,
34
+ activeOverrides: input.activeOverrides,
35
+ compiled: input.compiled,
36
+ request: {
37
+ messages,
38
+ tools: [...input.tools],
39
+ },
40
+ candidateUserPromptIncluded: input.candidateUserPrompt !== undefined,
41
+ });
42
+ }
43
+ }
@@ -0,0 +1,310 @@
1
+ import type { ModelContextBudget } from "../model/model-context-profile";
2
+ import type {
3
+ ModelRequestOutput,
4
+ ModelUsage,
5
+ PreparedModelRequest,
6
+ } from "../model/model-client";
7
+ import {
8
+ assertContextBudget,
9
+ contextPressure,
10
+ type ContextPressure,
11
+ type ContextUsageSource,
12
+ } from "../model/model-request-preflight";
13
+ import { lastPromptPrefixHash, promptPrefixHashes } from "../model/prompt-prefix-hash";
14
+ import {
15
+ estimatePromptSegments,
16
+ RollingTokenCalibration,
17
+ type RawContextBreakdown,
18
+ } from "../model/token-estimator";
19
+
20
+ export type MeasuredContextAnchor = {
21
+ readonly totalTokens: number;
22
+ readonly promptTokens: number;
23
+ readonly completionTokens: number;
24
+ readonly segmentCount: number;
25
+ readonly prefixHash: string;
26
+ readonly requestConfigHash: string;
27
+ readonly toolSchemaHash: string;
28
+ };
29
+
30
+ export type ContextUsageSnapshot = {
31
+ usedInputTokens: number;
32
+ source: ContextUsageSource;
33
+ pressure: ContextPressure;
34
+ inputBudgetTokens: number;
35
+ triggerTokens: number;
36
+ triggerRatio: number;
37
+ requestMaxOutputTokens: number;
38
+ lastProviderUsage?: ModelUsage;
39
+ rawFullEstimate?: RawContextBreakdown;
40
+ rawDeltaTokens?: number;
41
+ guardedDeltaTokens?: number;
42
+ correctionFactor: number;
43
+ calibrationSampleCount: number;
44
+ prefixHash: string;
45
+ requestConfigHash: string;
46
+ toolSchemaHash: string;
47
+ };
48
+
49
+ export type ContextInvalidationReason =
50
+ | "request_config_changed"
51
+ | "tool_schema_changed"
52
+ | "context_rebuilt"
53
+ | "runtime_reset";
54
+
55
+ type PreparedMeasurement = {
56
+ rawFullEstimate: RawContextBreakdown;
57
+ snapshot: ContextUsageSnapshot;
58
+ };
59
+
60
+ export class ContextMeter {
61
+ private readonly calibration = new RollingTokenCalibration();
62
+ private measurements = new WeakMap<object, PreparedMeasurement>();
63
+ private anchor?: MeasuredContextAnchor;
64
+ private lastProviderUsage?: ModelUsage;
65
+ private calibrationIdentity?: string;
66
+
67
+ constructor(
68
+ private readonly budget: ModelContextBudget,
69
+ private readonly options: {
70
+ enableAnchor?: boolean;
71
+ onMeasuredAnchor?: (anchor: MeasuredContextAnchor) => void;
72
+ } = {},
73
+ ) {}
74
+
75
+ restoreExactMeasuredAnchor(
76
+ prepared: PreparedModelRequest,
77
+ anchor: MeasuredContextAnchor,
78
+ ): boolean {
79
+ this.requireMatchingOutputLimit(prepared);
80
+ this.refreshCalibrationIdentity(prepared);
81
+ assertMeasuredContextAnchor(anchor);
82
+
83
+ const prefixHashes = promptPrefixHashes(
84
+ prepared.requestConfigHash,
85
+ prepared.promptSegments,
86
+ );
87
+ if (
88
+ anchor.requestConfigHash !== prepared.requestConfigHash ||
89
+ anchor.toolSchemaHash !== prepared.toolSchemaHash ||
90
+ anchor.segmentCount !== prepared.promptSegments.length ||
91
+ anchor.prefixHash !== lastPromptPrefixHash(prefixHashes)
92
+ ) {
93
+ this.anchor = undefined;
94
+ this.lastProviderUsage = undefined;
95
+ return false;
96
+ }
97
+
98
+ this.anchor = Object.freeze({ ...anchor });
99
+ this.lastProviderUsage = {
100
+ promptTokens: anchor.promptTokens,
101
+ completionTokens: anchor.completionTokens,
102
+ totalTokens: anchor.totalTokens,
103
+ };
104
+ return true;
105
+ }
106
+
107
+ measure(prepared: PreparedModelRequest): ContextUsageSnapshot {
108
+ this.requireMatchingOutputLimit(prepared);
109
+ this.refreshCalibrationIdentity(prepared);
110
+
111
+ const rawFullEstimate = estimatePromptSegments(prepared.promptSegments);
112
+ const correctionFactor = this.calibration.correctionFactor();
113
+ const prefixHashes = promptPrefixHashes(
114
+ prepared.requestConfigHash,
115
+ prepared.promptSegments,
116
+ );
117
+ const prefixHash = lastPromptPrefixHash(prefixHashes);
118
+ const anchor = this.usableAnchor(prepared, prefixHashes);
119
+
120
+ let source: ContextUsageSource;
121
+ let usedInputTokens: number;
122
+ let rawDeltaTokens: number | undefined;
123
+ let guardedDeltaTokens: number | undefined;
124
+ if (anchor === undefined) {
125
+ source = "estimated_full";
126
+ usedInputTokens = Math.ceil(rawFullEstimate.totalTokens * correctionFactor);
127
+ } else {
128
+ source = "measured_plus_estimated_delta";
129
+ rawDeltaTokens = estimatePromptSegments(
130
+ prepared.promptSegments.slice(anchor.segmentCount),
131
+ ).totalTokens;
132
+ guardedDeltaTokens = Math.ceil(rawDeltaTokens * correctionFactor);
133
+ usedInputTokens = anchor.totalTokens + guardedDeltaTokens;
134
+ }
135
+
136
+ const snapshot: ContextUsageSnapshot = {
137
+ usedInputTokens,
138
+ source,
139
+ pressure: contextPressure(usedInputTokens, this.budget),
140
+ inputBudgetTokens: this.budget.inputBudgetTokens,
141
+ triggerTokens: this.budget.triggerTokens,
142
+ triggerRatio: this.budget.triggerRatio,
143
+ requestMaxOutputTokens: this.budget.requestMaxOutputTokens,
144
+ ...(this.lastProviderUsage === undefined
145
+ ? {}
146
+ : { lastProviderUsage: { ...this.lastProviderUsage } }),
147
+ rawFullEstimate,
148
+ ...(rawDeltaTokens === undefined ? {} : { rawDeltaTokens }),
149
+ ...(guardedDeltaTokens === undefined ? {} : { guardedDeltaTokens }),
150
+ correctionFactor,
151
+ calibrationSampleCount: this.calibration.sampleCount(),
152
+ prefixHash,
153
+ requestConfigHash: prepared.requestConfigHash,
154
+ toolSchemaHash: prepared.toolSchemaHash,
155
+ };
156
+ this.measurements.set(prepared, { rawFullEstimate, snapshot });
157
+ return snapshot;
158
+ }
159
+
160
+ recordProviderUsage(
161
+ prepared: PreparedModelRequest,
162
+ output: ModelRequestOutput,
163
+ ): ContextUsageSnapshot {
164
+ const measurement = this.measurements.get(prepared);
165
+ if (measurement === undefined) {
166
+ throw new Error("Cannot record provider usage before measuring the request.");
167
+ }
168
+
169
+ this.calibration.record(
170
+ output.usage.promptTokens,
171
+ measurement.rawFullEstimate.totalTokens,
172
+ );
173
+ this.lastProviderUsage = { ...output.usage };
174
+ const replaySegments = prepared.assistantReplaySegments(output.message);
175
+ const anchoredSegments = [...prepared.promptSegments, ...replaySegments];
176
+ const prefixHash = lastPromptPrefixHash(
177
+ promptPrefixHashes(prepared.requestConfigHash, anchoredSegments),
178
+ );
179
+ if (this.options.enableAnchor !== false) {
180
+ const anchor = Object.freeze({
181
+ totalTokens: output.usage.totalTokens,
182
+ promptTokens: output.usage.promptTokens,
183
+ completionTokens: output.usage.completionTokens,
184
+ segmentCount: anchoredSegments.length,
185
+ prefixHash,
186
+ requestConfigHash: prepared.requestConfigHash,
187
+ toolSchemaHash: prepared.toolSchemaHash,
188
+ });
189
+ this.anchor = anchor;
190
+ this.options.onMeasuredAnchor?.(anchor);
191
+ }
192
+
193
+ const usedInputTokens = output.usage.totalTokens;
194
+ return {
195
+ usedInputTokens,
196
+ source: "provider_measured",
197
+ pressure: contextPressure(usedInputTokens, this.budget),
198
+ inputBudgetTokens: this.budget.inputBudgetTokens,
199
+ triggerTokens: this.budget.triggerTokens,
200
+ triggerRatio: this.budget.triggerRatio,
201
+ requestMaxOutputTokens: this.budget.requestMaxOutputTokens,
202
+ lastProviderUsage: { ...output.usage },
203
+ rawFullEstimate: measurement.rawFullEstimate,
204
+ correctionFactor: this.calibration.correctionFactor(),
205
+ calibrationSampleCount: this.calibration.sampleCount(),
206
+ prefixHash,
207
+ requestConfigHash: prepared.requestConfigHash,
208
+ toolSchemaHash: prepared.toolSchemaHash,
209
+ };
210
+ }
211
+
212
+ assertWithinBudget(snapshot: ContextUsageSnapshot): void {
213
+ assertContextBudget({
214
+ usedInputTokens: snapshot.usedInputTokens,
215
+ source: snapshot.source,
216
+ ...this.budget,
217
+ });
218
+ }
219
+
220
+ startRevision(input: {
221
+ reason: "context_rebuilt";
222
+ requestConfigHash: string;
223
+ toolSchemaHash: string;
224
+ }): void {
225
+ const nextIdentity = `${input.requestConfigHash}:${input.toolSchemaHash}`;
226
+ if (
227
+ this.calibrationIdentity !== undefined &&
228
+ this.calibrationIdentity !== nextIdentity
229
+ ) {
230
+ this.anchor = undefined;
231
+ this.lastProviderUsage = undefined;
232
+ this.measurements = new WeakMap();
233
+ this.calibration.clear();
234
+ this.calibrationIdentity = nextIdentity;
235
+ throw new Error(
236
+ "Context revision changed the request configuration or tool schema.",
237
+ );
238
+ }
239
+ this.anchor = undefined;
240
+ this.lastProviderUsage = undefined;
241
+ this.measurements = new WeakMap();
242
+ this.calibrationIdentity = nextIdentity;
243
+ }
244
+
245
+ invalidate(reason: ContextInvalidationReason): void {
246
+ void reason;
247
+ this.anchor = undefined;
248
+ this.lastProviderUsage = undefined;
249
+ this.measurements = new WeakMap();
250
+ this.calibration.clear();
251
+ this.calibrationIdentity = undefined;
252
+ }
253
+
254
+ private usableAnchor(
255
+ prepared: PreparedModelRequest,
256
+ prefixHashes: readonly string[],
257
+ ): MeasuredContextAnchor | undefined {
258
+ const anchor = this.anchor;
259
+ if (anchor === undefined) {
260
+ return undefined;
261
+ }
262
+ if (
263
+ anchor.requestConfigHash !== prepared.requestConfigHash ||
264
+ anchor.toolSchemaHash !== prepared.toolSchemaHash ||
265
+ anchor.segmentCount > prepared.promptSegments.length ||
266
+ prefixHashes[anchor.segmentCount] !== anchor.prefixHash
267
+ ) {
268
+ this.anchor = undefined;
269
+ return undefined;
270
+ }
271
+ return anchor;
272
+ }
273
+
274
+ private requireMatchingOutputLimit(prepared: PreparedModelRequest): void {
275
+ if (prepared.requestMaxOutputTokens !== this.budget.requestMaxOutputTokens) {
276
+ throw new Error(
277
+ `Prepared request output limit must equal the context budget: expected ${this.budget.requestMaxOutputTokens}, received ${prepared.requestMaxOutputTokens}.`,
278
+ );
279
+ }
280
+ }
281
+
282
+ private refreshCalibrationIdentity(prepared: PreparedModelRequest): void {
283
+ const next = `${prepared.requestConfigHash}:${prepared.toolSchemaHash}`;
284
+ if (this.calibrationIdentity !== undefined && this.calibrationIdentity !== next) {
285
+ this.anchor = undefined;
286
+ this.calibration.clear();
287
+ }
288
+ this.calibrationIdentity = next;
289
+ }
290
+ }
291
+
292
+ function assertMeasuredContextAnchor(anchor: MeasuredContextAnchor): void {
293
+ for (const [name, value] of [
294
+ ["promptTokens", anchor.promptTokens],
295
+ ["completionTokens", anchor.completionTokens],
296
+ ["totalTokens", anchor.totalTokens],
297
+ ["segmentCount", anchor.segmentCount],
298
+ ] as const) {
299
+ if (!Number.isSafeInteger(value) || value < 0) {
300
+ throw new Error(
301
+ `Measured context anchor ${name} must be a non-negative safe integer; received ${value}.`,
302
+ );
303
+ }
304
+ }
305
+ if (anchor.totalTokens !== anchor.promptTokens + anchor.completionTokens) {
306
+ throw new Error(
307
+ "Measured context anchor totalTokens must equal promptTokens + completionTokens.",
308
+ );
309
+ }
310
+ }