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.
- package/README.md +173 -0
- package/package.json +78 -0
- package/patches/markdansi@0.3.2.patch +37 -0
- package/src/agent/context-builder.ts +43 -0
- package/src/agent/context-meter.ts +310 -0
- package/src/agent/loop.ts +525 -0
- package/src/agent/runtime-session.ts +1212 -0
- package/src/agent/session-ledger.ts +828 -0
- package/src/agent/turn-cancellation.ts +44 -0
- package/src/agent/types.ts +77 -0
- package/src/cli/config.ts +283 -0
- package/src/cli/index.ts +29 -0
- package/src/cli/model-profiles.ts +289 -0
- package/src/cli/run-runner.ts +107 -0
- package/src/cli/tui-runner.tsx +290 -0
- package/src/context/compiled-context-hash.ts +138 -0
- package/src/context/compiled-context-validator.ts +209 -0
- package/src/context/context-manager.ts +362 -0
- package/src/context/context-policy.ts +8 -0
- package/src/context/context-protocol-validator.ts +463 -0
- package/src/context/context-revision-compiler.ts +281 -0
- package/src/context/context-revision.ts +111 -0
- package/src/context/context-source.ts +30 -0
- package/src/context/context-swap-renderer.ts +272 -0
- package/src/context/protocol-frame.ts +240 -0
- package/src/context/swap-planner.ts +725 -0
- package/src/events/append-private-file.ts +16 -0
- package/src/events/bash-result-detail.ts +70 -0
- package/src/events/composite-event-sink.ts +82 -0
- package/src/events/event-sink.ts +16 -0
- package/src/events/jsonl-event-log.ts +13 -0
- package/src/events/observation-text-log.ts +195 -0
- package/src/events/stdout-event-printer.ts +396 -0
- package/src/events/types.ts +263 -0
- package/src/ids/runtime-id.ts +68 -0
- package/src/ids/uuid-v7.ts +5 -0
- package/src/instructions/project-instructions.ts +242 -0
- package/src/mcp/mcp-config.ts +144 -0
- package/src/mcp/mcp-manager.ts +216 -0
- package/src/mcp/mcp-tool-executor.ts +178 -0
- package/src/model/committed-prefix-auditor.ts +68 -0
- package/src/model/fake-model-client.ts +280 -0
- package/src/model/model-client.ts +64 -0
- package/src/model/model-context-profile.ts +134 -0
- package/src/model/model-request-preflight.ts +120 -0
- package/src/model/openai-chat-mapping.ts +444 -0
- package/src/model/openai-chat-model-client.ts +190 -0
- package/src/model/prompt-prefix-hash.ts +47 -0
- package/src/model/token-estimator.ts +148 -0
- package/src/observation/observation-builder.ts +481 -0
- package/src/session/resume-projection.ts +616 -0
- package/src/session/session-catalog.ts +270 -0
- package/src/session/session-errors.ts +121 -0
- package/src/session/session-history-reader.ts +535 -0
- package/src/session/session-lock.ts +291 -0
- package/src/session/session-schema.ts +741 -0
- package/src/session/session-store.ts +3067 -0
- package/src/session/sqlite-session-ledger.ts +153 -0
- package/src/tools/bash-task.ts +617 -0
- package/src/tools/bash.ts +450 -0
- package/src/tools/cwd-state.ts +22 -0
- package/src/tools/edit.ts +428 -0
- package/src/tools/file-diff.ts +116 -0
- package/src/tools/glob.ts +202 -0
- package/src/tools/grep.ts +550 -0
- package/src/tools/hash.ts +9 -0
- package/src/tools/path-safety.ts +33 -0
- package/src/tools/read.ts +319 -0
- package/src/tools/recall.ts +400 -0
- package/src/tools/registry.ts +213 -0
- package/src/tools/ripgrep.ts +220 -0
- package/src/tools/task-list.ts +59 -0
- package/src/tools/task-output-snapshot.ts +47 -0
- package/src/tools/task-output-tool.ts +62 -0
- package/src/tools/task-output.ts +159 -0
- package/src/tools/task-stop.ts +59 -0
- package/src/tools/task-tool-args.ts +29 -0
- package/src/tools/types.ts +330 -0
- package/src/tools/web-fetch/backend.ts +27 -0
- package/src/tools/web-fetch/browser-backend.ts +126 -0
- package/src/tools/web-fetch/exa-backend.ts +172 -0
- package/src/tools/web-fetch/index.ts +298 -0
- package/src/tools/web-fetch/local-backend.ts +267 -0
- package/src/tools/web-fetch/refiner.ts +78 -0
- package/src/tools/web-fetch/route.ts +95 -0
- package/src/tools/web-search.ts +300 -0
- package/src/tools/write.ts +244 -0
- package/src/tui/app.tsx +497 -0
- package/src/tui/components/assistant-markdown.tsx +47 -0
- package/src/tui/components/background-tasks.tsx +92 -0
- package/src/tui/components/bash-result-view.tsx +47 -0
- package/src/tui/components/context-status.tsx +127 -0
- package/src/tui/components/diff-view.tsx +151 -0
- package/src/tui/components/file-viewer.tsx +212 -0
- package/src/tui/components/footer.tsx +60 -0
- package/src/tui/components/header.tsx +21 -0
- package/src/tui/components/model-picker.tsx +142 -0
- package/src/tui/components/prompt-input.tsx +432 -0
- package/src/tui/components/resume-session-picker.tsx +273 -0
- package/src/tui/components/timeline.tsx +121 -0
- package/src/tui/context-format.ts +24 -0
- package/src/tui/event-store.ts +865 -0
- package/src/tui/git-branch.ts +23 -0
- package/src/tui/line-editor.ts +157 -0
- package/src/tui/prompt-history.ts +94 -0
- package/src/tui/slash-commands.ts +126 -0
- package/src/tui/tui-projection-policy.ts +35 -0
- package/src/tui/tui-projection-store.ts +123 -0
- package/src/tui/tui-session-controller.ts +170 -0
- 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
|
+
}
|