tinker-agent 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.6.0] - 2026-09-03
9
+
10
+ ### Added
11
+
12
+ - Show the installed Tinker version at the end of the interactive prompt status
13
+ bar.
14
+
15
+ ### Changed
16
+
17
+ - Raise the default maximum agent-loop iterations per turn from 512 to 65,536,
18
+ allowing longer autonomous tasks without requiring configuration changes.
19
+
20
+ ### Fixed
21
+
22
+ - Preserve brace expressions in Grep glob filters, so patterns such as
23
+ `**/*.{ts,tsx}` are passed to ripgrep intact.
24
+
8
25
  ## [2.5.0] - 2026-09-03
9
26
 
10
27
  ### Added
@@ -307,7 +324,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
307
324
  - First formal npm release under the `tinker-agent` package name with the `tinker`
308
325
  executable.
309
326
 
310
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.5.0...HEAD
327
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.6.0...HEAD
328
+ [2.6.0]: https://github.com/ishowshao/tinker/releases/tag/v2.6.0
311
329
  [2.5.0]: https://github.com/ishowshao/tinker/releases/tag/v2.5.0
312
330
  [2.4.0]: https://github.com/ishowshao/tinker/releases/tag/v2.4.0
313
331
  [2.3.0]: https://github.com/ishowshao/tinker/releases/tag/v2.3.0
package/README.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Tinker
2
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.
3
+ **Tinker** is a personal coding-agent harness — an interactive TUI (Terminal User Interface) and one-shot CLI that drives an LLM in an agent loop with file, search, shell, and MCP tools to read and modify a local workspace.
4
4
 
5
- Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://github.com/vadimdemedes/ink) (React for CLIs).
5
+ **Tinker is designed for models that work over extremely long horizons — potentially as persistent agents that continue indefinitely, rather than as disposable chat sessions.** Its architecture treats the model's context window as a bounded working set, not as the source of truth. Immutable canonical history, durable sessions, protocol-safe recovery, deterministic context revisions, Recall-addressable cold state, and context-pressure management allow work to continue across compaction, process restarts, and context-window limits.
6
+
7
+ This does not pretend that any model has infinite tokens or guarantee that it will recall every relevant fact. It means the harness is designed so that history remains durable and recoverable while the model repeatedly operates on a bounded, valid view of an ongoing session.
6
8
 
7
9
  ## Features
8
10
 
@@ -27,16 +29,36 @@ Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://gith
27
29
  - **Turn cancellation**: Users can cancel an ongoing turn safely, with protocol-safe synthetic tool messages.
28
30
  - **Context metering**: Budget-aware context management with protocol validation before sending requests to the model.
29
31
  - **Deterministic context compaction**: Idle sessions can swap eligible historical tool output into Recall-addressable placeholders without calling the model.
30
- - **Infinite Context architecture**: Immutable canonical history, deterministic
31
- context revisions, Recall-addressable cold state, and qualified prefix retirement
32
- keep long-running sessions recoverable without pretending the model has infinite
33
- tokens. See the [technical design](docs/infinite-context-technical-design.md).
32
+ - **Infinite Context architecture**: Immutable canonical history, deterministic context revisions, Recall-addressable cold state, and qualified prefix retirement support sessions designed to continue indefinitely without pretending the model has infinite tokens. See the [technical design](docs/infinite-context-technical-design.md).
34
33
  - **Choice of models**: Uses an OpenAI-compatible Chat Completions transport with
35
34
  explicit model and context limits. Actual provider support must be established
36
35
  by a qualification matrix; transport compatibility alone is not a guarantee.
37
36
 
38
37
  ## Quick Start
39
38
 
39
+ ### Install with your existing agent
40
+
41
+ If you already use a coding agent, you can ask it to read Tinker's documentation, install the package, and prepare a complete local configuration for you. Values it cannot safely determine can be left as clearly named placeholders; when setup is complete, the agent should tell you exactly which file to edit and what each placeholder expects.
42
+
43
+ Copy and send this prompt to your existing agent:
44
+
45
+ ```text
46
+ Open https://github.com/ishowshao/tinker and read Tinker's README and linked model-configuration documentation, then install and configure Tinker on this machine.
47
+
48
+ Create a complete model profile configuration in an appropriate local file. Use clearly named placeholders for any required values that cannot be determined from the documentation or the current environment; do not invent API credentials, endpoint URLs, model names, or model limits. Configure Tinker to use that file, and verify the installation and configuration as far as possible without making a live model request.
49
+
50
+ When finished, tell me:
51
+ 1. the exact path of the configuration file you created;
52
+ 2. every placeholder I still need to replace;
53
+ 3. what value each placeholder expects;
54
+ 4. any command I need to run after filling them in;
55
+ 5. the command to start Tinker.
56
+
57
+ Do not ask me for the missing values during setup unless proceeding would be unsafe. Prefer creating a structurally complete configuration with placeholders so I can fill them in locally afterward.
58
+ ```
59
+
60
+ ### Install manually
61
+
40
62
  ```bash
41
63
  npm install --global tinker-agent
42
64
 
@@ -149,7 +171,7 @@ are required. Boolean environment values accept case-insensitive `true/false`,
149
171
  | `TINKER_STREAM` | Model | Env mode | No | Boolean | `true` | No | Use streaming transport for the selected model API. |
150
172
  | `TINKER_WEBFETCH_REFINE_MODEL` | Model | Env mode | No | Non-empty string | — | No | Optional WebFetch refiner model; currently must match TINKER_MODEL. |
151
173
  | `TINKER_WORKSPACE` | Workspace | All modes | No | Non-empty string | Process cwd | No | Workspace path. A leading ~ expands to the home directory; other relative paths resolve from the process cwd. |
152
- | `TINKER_MAX_ITERATIONS` | Workspace | All modes | No | Positive integer | `512` | No | Maximum agent-loop iterations per turn. |
174
+ | `TINKER_MAX_ITERATIONS` | Workspace | All modes | No | Positive integer | `65536` | No | Maximum agent-loop iterations per turn. |
153
175
  | `EXA_API_KEY` | Tooling | All modes | No | Non-empty string | — | Yes | Enables WebSearch and the Exa WebFetch backend when set. |
154
176
  | `TINKER_MCP_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `60000` | No | MCP tool-call timeout in milliseconds. |
155
177
  | `TINKER_MCP_MAX_OBSERVATION_CHARS` | Tooling | All modes | No | Positive integer | `40000` | No | Maximum model-visible characters in one MCP result. |
@@ -513,6 +535,7 @@ Runtime data lives in ~/.tinker/ (sessions, bash tasks, assets), not in the repo
513
535
  - **Fast-fail**: Validate assumptions early and return clear errors close to the source. Structured failures allow the model to correct and retry.
514
536
  - **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.
515
537
  - **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.
538
+ - **Long-horizon continuity**: Treat the context window as a replaceable working set over durable canonical history. Sessions should remain resumable and historically recoverable across compaction, interruption, and process restarts.
516
539
  - **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.
517
540
 
518
541
  ## Requirements
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/cli/main.ts CHANGED
@@ -51,6 +51,7 @@ type TuiRunner = {
51
51
  readonly publicConfig: ResolvedPublicConfig;
52
52
  readonly initialRunnerConfig: RunnerConfig;
53
53
  readonly env: NodeJS.ProcessEnv;
54
+ readonly version: string;
54
55
  }) => Promise<void>;
55
56
  };
56
57
 
@@ -187,6 +188,7 @@ export async function main(
187
188
  publicConfig,
188
189
  initialRunnerConfig: runnerConfig,
189
190
  env,
191
+ version: metadata.version,
190
192
  });
191
193
  return finish(0);
192
194
  } catch (error) {
@@ -135,7 +135,7 @@ export const PUBLIC_CONFIG_FIELDS = Object.freeze([
135
135
  valueKind: "positive-integer",
136
136
  requiredIn: "never",
137
137
  appliesIn: "always",
138
- defaultValue: 512,
138
+ defaultValue: 65_536,
139
139
  secret: false,
140
140
  section: "workspace",
141
141
  description: "Maximum agent-loop iterations per turn.",
@@ -54,6 +54,7 @@ export type RunTuiOptions = {
54
54
  readonly publicConfig: ResolvedPublicConfig;
55
55
  readonly initialRunnerConfig: RunnerConfig;
56
56
  readonly env: NodeJS.ProcessEnv;
57
+ readonly version: string;
57
58
  };
58
59
 
59
60
  export async function runTui(options: RunTuiOptions): Promise<void> {
@@ -286,6 +287,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
286
287
  instance = render(
287
288
  <App
288
289
  sessionController={controller}
290
+ version={options.version}
289
291
  readGitBranch={readCurrentGitBranch}
290
292
  history={promptHistory}
291
293
  projectSlashCommands={projectSlashCommands}
@@ -0,0 +1,249 @@
1
+ import path from "node:path";
2
+ import { chmod, lstat, open, readFile } from "node:fs/promises";
3
+ import { Database } from "bun:sqlite";
4
+ import type { SessionId } from "../ids/runtime-id";
5
+ import { stableJsonStringify } from "../model/model-request-preflight";
6
+ import type { ProtocolContextView } from "../context/protocol-frame";
7
+ import {
8
+ canonicalSequenceHash,
9
+ renderedMessageHash,
10
+ } from "../context/compiled-context-hash";
11
+ import { ContextRevisionCompiler } from "../context/context-revision-compiler";
12
+ import type { AgentEvent } from "../events/types";
13
+ import { renderObservationLogEvent } from "../events/observation-text-log";
14
+ import type { CloneSessionFaultStage } from "./session-store-contracts";
15
+ import { validateSecureFile } from "./session-store-filesystem";
16
+ import {
17
+ decodeContextRevision,
18
+ decodeContextSurface,
19
+ decodeStoredSwapOverride,
20
+ decodeStoredToolCalls,
21
+ protocolPrefixView,
22
+ } from "./session-store-record-codecs";
23
+
24
+ export const SESSION_SCOPED_TABLES = [
25
+ "session_meta",
26
+ "turns",
27
+ "iterations",
28
+ "protocol_frames",
29
+ "messages",
30
+ "tool_results",
31
+ "context_surfaces",
32
+ "context_revisions",
33
+ "context_overrides",
34
+ "skill_activations",
35
+ "context_measurement_state",
36
+ ] as const;
37
+
38
+ export function rekeyStoredToolCalls(
39
+ database: Database,
40
+ targetSessionId: SessionId,
41
+ ): void {
42
+ const rows = database
43
+ .query(
44
+ `SELECT message_id, tool_calls_json FROM messages
45
+ WHERE tool_calls_json IS NOT NULL ORDER BY ordinal`,
46
+ )
47
+ .all() as Array<{ message_id: string; tool_calls_json: string }>;
48
+ for (const row of rows) {
49
+ const calls = decodeStoredToolCalls(row.tool_calls_json).map((call) => ({
50
+ ...call,
51
+ sessionId: targetSessionId,
52
+ }));
53
+ database
54
+ .query("UPDATE messages SET tool_calls_json = ? WHERE message_id = ?")
55
+ .run(stableJsonStringify(calls), row.message_id);
56
+ }
57
+ }
58
+
59
+ export function rekeyProtocolView(
60
+ source: ProtocolContextView,
61
+ targetSessionId: SessionId,
62
+ ): ProtocolContextView {
63
+ return {
64
+ sessionId: targetSessionId,
65
+ faulted: source.faulted,
66
+ frames: source.frames.map((frame) => ({
67
+ ...frame,
68
+ sessionId: targetSessionId,
69
+ })),
70
+ messages: source.messages.map((message) => ({
71
+ ...message,
72
+ sessionId: targetSessionId,
73
+ ...(message.role === "assistant" && message.toolCalls !== undefined
74
+ ? {
75
+ toolCalls: message.toolCalls.map((call) => ({
76
+ ...call,
77
+ sessionId: targetSessionId,
78
+ })),
79
+ }
80
+ : {}),
81
+ })),
82
+ toolResults: source.toolResults.map((result) => ({
83
+ ...result,
84
+ sessionId: targetSessionId,
85
+ })),
86
+ };
87
+ }
88
+
89
+ export function rewriteCloneRevisionHashes(
90
+ database: Database,
91
+ canonical: ProtocolContextView,
92
+ ): void {
93
+ const surfaces = database
94
+ .query("SELECT * FROM context_surfaces")
95
+ .all()
96
+ .map(decodeContextSurface);
97
+ const surfacesById = new Map(surfaces.map((surface) => [surface.surfaceId, surface]));
98
+ const revisions = database
99
+ .query("SELECT * FROM context_revisions ORDER BY revision_number")
100
+ .all()
101
+ .map(decodeContextRevision);
102
+ const revisionNumberById = new Map(
103
+ revisions.map((revision) => [revision.revisionId, revision.revisionNumber]),
104
+ );
105
+ const overrides = database
106
+ .query(
107
+ `SELECT co.* FROM context_overrides co
108
+ JOIN context_revisions cr ON cr.revision_id = co.introduced_revision_id
109
+ ORDER BY cr.revision_number, co.ordinal`,
110
+ )
111
+ .all()
112
+ .map(decodeStoredSwapOverride);
113
+ const compiler = new ContextRevisionCompiler();
114
+ for (const revision of revisions) {
115
+ const surface = surfacesById.get(revision.surfaceId);
116
+ if (surface === undefined) {
117
+ throw new Error(`Cloned revision ${revision.revisionId} has no surface.`);
118
+ }
119
+ const activeOverrides = overrides.filter(
120
+ (override) =>
121
+ (revisionNumberById.get(override.introducedRevisionId) ??
122
+ Number.POSITIVE_INFINITY) <= revision.revisionNumber &&
123
+ override.ordinal >= revision.keepFromOrdinal,
124
+ );
125
+ const prefix = protocolPrefixView(canonical, revision.sourceThroughOrdinal);
126
+ const compiled = compiler.compileForIdentityRekey({
127
+ canonical: prefix,
128
+ revisionId: revision.revisionId,
129
+ activeOverrides,
130
+ keepFromOrdinal: revision.keepFromOrdinal,
131
+ surface,
132
+ });
133
+ database
134
+ .query(
135
+ `UPDATE context_revisions
136
+ SET canonical_sequence_sha256 = ?, rendered_message_sha256 = ?
137
+ WHERE revision_id = ?`,
138
+ )
139
+ .run(
140
+ canonicalSequenceHash(canonical, revision.sourceThroughOrdinal),
141
+ renderedMessageHash(compiled.entries, revision.sourceThroughOrdinal),
142
+ revision.revisionId,
143
+ );
144
+ }
145
+ }
146
+
147
+ export async function cloneDiagnosticFiles(input: {
148
+ sourceDirectory: string;
149
+ stagingDirectory: string;
150
+ sourceSessionId: SessionId;
151
+ targetSessionId: SessionId;
152
+ nextEventSequence: number;
153
+ faultInjector?: (stage: CloneSessionFaultStage) => void;
154
+ }): Promise<void> {
155
+ const sourcePath = path.join(input.sourceDirectory, "events.jsonl");
156
+ try {
157
+ await lstat(sourcePath);
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
160
+ return;
161
+ }
162
+ throw error;
163
+ }
164
+ await validateSecureFile(sourcePath, input.sourceSessionId);
165
+
166
+ const bytes = await readFile(sourcePath);
167
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
168
+ const rawLines = text.split("\n");
169
+ if (rawLines.at(-1) === "") {
170
+ rawLines.pop();
171
+ }
172
+ const events: AgentEvent[] = [];
173
+ let previousSequence = 0;
174
+ for (const [index, line] of rawLines.entries()) {
175
+ if (line === "") {
176
+ throw new Error(`Session event log contains an empty line at ${index + 1}.`);
177
+ }
178
+ let value: unknown;
179
+ try {
180
+ value = JSON.parse(line);
181
+ } catch (error) {
182
+ throw new Error(`Session event log has invalid JSON at line ${index + 1}.`, {
183
+ cause: error,
184
+ });
185
+ }
186
+ if (!isEventEnvelope(value)) {
187
+ throw new Error(
188
+ `Session event log has an invalid envelope at line ${index + 1}.`,
189
+ );
190
+ }
191
+ if (value.sessionId !== input.sourceSessionId) {
192
+ throw new Error(`Session event log identity changed at line ${index + 1}.`);
193
+ }
194
+ if (value.eventSequence <= previousSequence) {
195
+ throw new Error(
196
+ `Session event sequence is not strictly increasing at line ${index + 1}.`,
197
+ );
198
+ }
199
+ if (value.eventSequence >= input.nextEventSequence) {
200
+ throw new Error(
201
+ `Session event sequence exceeds the canonical next counter at line ${index + 1}.`,
202
+ );
203
+ }
204
+ previousSequence = value.eventSequence;
205
+ events.push({ ...value, sessionId: input.targetSessionId });
206
+ }
207
+
208
+ const eventText = events.map((event) => JSON.stringify(event)).join("\n");
209
+ await writePrivateNewFile(
210
+ path.join(input.stagingDirectory, "events.jsonl"),
211
+ eventText === "" ? "" : `${eventText}\n`,
212
+ );
213
+ input.faultInjector?.("after_event_rewrite");
214
+ const observationText = events
215
+ .map((event) => renderObservationLogEvent(event))
216
+ .filter((block): block is string => block !== undefined)
217
+ .join("");
218
+ await writePrivateNewFile(
219
+ path.join(input.stagingDirectory, "observations.md"),
220
+ observationText,
221
+ );
222
+ input.faultInjector?.("after_observation_render");
223
+ }
224
+
225
+ function isEventEnvelope(value: unknown): value is AgentEvent {
226
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
227
+ return false;
228
+ }
229
+ const record = value as Record<string, unknown>;
230
+ return (
231
+ typeof record.sessionId === "string" &&
232
+ Number.isSafeInteger(record.eventSequence) &&
233
+ Number(record.eventSequence) >= 1 &&
234
+ typeof record.timestamp === "string" &&
235
+ typeof record.type === "string" &&
236
+ record.data !== null &&
237
+ typeof record.data === "object"
238
+ );
239
+ }
240
+
241
+ async function writePrivateNewFile(filePath: string, content: string): Promise<void> {
242
+ const handle = await open(filePath, "wx", 0o600);
243
+ try {
244
+ await handle.writeFile(content, "utf8");
245
+ } finally {
246
+ await handle.close();
247
+ }
248
+ await chmod(filePath, 0o600);
249
+ }