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
@@ -0,0 +1,267 @@
1
+ import { Readability } from "@mozilla/readability";
2
+ import { parseHTML } from "linkedom";
3
+ import TurndownService from "turndown";
4
+ import { cancellationError, throwIfTurnCancelled } from "../../agent/turn-cancellation";
5
+ import type { WebFetchBackend, WebFetchBackendResult } from "./backend";
6
+
7
+ export type LocalBackendOptions = {
8
+ fetchImpl?: typeof fetch;
9
+ timeoutMs?: number;
10
+ maxBodyBytes?: number;
11
+ maxRedirects?: number;
12
+ };
13
+
14
+ const DEFAULT_TIMEOUT_MS = 30_000;
15
+ const DEFAULT_MAX_BODY_BYTES = 5 * 1024 * 1024;
16
+ const DEFAULT_MAX_REDIRECTS = 5;
17
+
18
+ // Some sites (e.g. mp.weixin.qq.com) serve an anti-bot page to non-browser
19
+ // user agents; present a regular Chrome UA instead of Bun's default.
20
+ const CHROME_USER_AGENT =
21
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
22
+ "(KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36";
23
+
24
+ export function createLocalWebFetchBackend(
25
+ options: LocalBackendOptions = {},
26
+ ): WebFetchBackend {
27
+ const fetchImpl = options.fetchImpl ?? fetch;
28
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
29
+ const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
30
+ const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
31
+
32
+ return {
33
+ route: "local",
34
+ async fetch(input, context): Promise<WebFetchBackendResult> {
35
+ throwIfTurnCancelled(context.signal);
36
+ let currentUrl = new URL(input.url);
37
+ let response: Response;
38
+
39
+ for (let redirects = 0; ; redirects += 1) {
40
+ try {
41
+ const requestSignal = AbortSignal.any([
42
+ context.signal,
43
+ AbortSignal.timeout(timeoutMs),
44
+ ]);
45
+ response = await fetchImpl(currentUrl.toString(), {
46
+ redirect: "manual",
47
+ signal: requestSignal,
48
+ headers: {
49
+ accept: "text/html, text/markdown;q=0.9, */*;q=0.8",
50
+ "accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
51
+ "user-agent": CHROME_USER_AGENT,
52
+ },
53
+ });
54
+ } catch (error) {
55
+ if (context.signal.aborted) {
56
+ throw cancellationError(context.signal, error);
57
+ }
58
+
59
+ return { ok: false, error: requestErrorMessage(error, timeoutMs) };
60
+ }
61
+
62
+ throwIfTurnCancelled(context.signal);
63
+
64
+ if (!isRedirectStatus(response.status)) {
65
+ break;
66
+ }
67
+
68
+ const location = response.headers.get("location");
69
+ if (location === null || location === "") {
70
+ return {
71
+ ok: false,
72
+ httpStatusCode: response.status,
73
+ error: `Redirect response (HTTP ${response.status}) is missing a location header.`,
74
+ };
75
+ }
76
+
77
+ const nextUrl = new URL(location, currentUrl);
78
+ if (nextUrl.hostname !== currentUrl.hostname) {
79
+ return { ok: true, redirectUrl: nextUrl.toString() };
80
+ }
81
+
82
+ if (redirects + 1 > maxRedirects) {
83
+ return {
84
+ ok: false,
85
+ error: `Too many redirects (more than ${maxRedirects}).`,
86
+ };
87
+ }
88
+
89
+ currentUrl = nextUrl;
90
+ }
91
+
92
+ if (!response.ok) {
93
+ return {
94
+ ok: false,
95
+ httpStatusCode: response.status,
96
+ error: `Request failed with HTTP ${response.status}.`,
97
+ };
98
+ }
99
+
100
+ const declaredLength = Number(response.headers.get("content-length") ?? "");
101
+ if (Number.isFinite(declaredLength) && declaredLength > maxBodyBytes) {
102
+ return {
103
+ ok: false,
104
+ error: `Response body exceeds the ${maxBodyBytes} byte limit.`,
105
+ };
106
+ }
107
+
108
+ let bodyBytes: ArrayBuffer;
109
+ try {
110
+ bodyBytes = await response.arrayBuffer();
111
+ } catch (error) {
112
+ if (context.signal.aborted) {
113
+ throw cancellationError(context.signal, error);
114
+ }
115
+
116
+ return {
117
+ ok: false,
118
+ error: `Failed to read response body: ${errorMessage(error)}`,
119
+ };
120
+ }
121
+
122
+ throwIfTurnCancelled(context.signal);
123
+
124
+ if (bodyBytes.byteLength > maxBodyBytes) {
125
+ return {
126
+ ok: false,
127
+ error: `Response body exceeds the ${maxBodyBytes} byte limit.`,
128
+ };
129
+ }
130
+
131
+ const body = new TextDecoder().decode(bodyBytes);
132
+ const contentType = mimeType(response.headers.get("content-type"));
133
+ const finalUrl = currentUrl.toString();
134
+
135
+ if (contentType === "text/html" || contentType === "application/xhtml+xml") {
136
+ const extracted = extractMarkdownFromHtml(body);
137
+ return {
138
+ ok: true,
139
+ finalUrl,
140
+ title: extracted.title,
141
+ markdown: extracted.markdown,
142
+ };
143
+ }
144
+
145
+ if (
146
+ contentType === "" ||
147
+ contentType.startsWith("text/") ||
148
+ contentType === "application/xml"
149
+ ) {
150
+ return { ok: true, finalUrl, markdown: body };
151
+ }
152
+
153
+ if (contentType === "application/json" || contentType.endsWith("+json")) {
154
+ return { ok: true, finalUrl, markdown: prettyPrintJson(body) };
155
+ }
156
+
157
+ return {
158
+ ok: false,
159
+ error: `Unsupported content type: ${contentType}. WebFetch supports HTML, text, markdown, and JSON.`,
160
+ };
161
+ },
162
+ };
163
+ }
164
+
165
+ // Accept the Readability extraction only if it keeps at least this share of
166
+ // the page text; below that it likely dropped the real content.
167
+ const READABILITY_MIN_KEEP_RATIO = 0.2;
168
+
169
+ // Article extraction only pays off on text-heavy pages. On small pages
170
+ // (app UIs, dashboards) Readability tends to pick one region and drop
171
+ // sidebars and controls, so convert the whole body instead.
172
+ const READABILITY_MIN_TEXT_CHARS = 4000;
173
+
174
+ export function extractMarkdownFromHtml(html: string): {
175
+ title?: string;
176
+ markdown: string;
177
+ } {
178
+ const { document } = parseHTML(html);
179
+
180
+ for (const node of document.querySelectorAll("script, style, noscript, template")) {
181
+ node.remove();
182
+ }
183
+
184
+ revealJsHiddenContent(document);
185
+
186
+ let title = document.querySelector("title")?.textContent?.trim() || undefined;
187
+ const fallbackHtml = document.body?.innerHTML ?? html;
188
+ const bodyTextLength = document.body?.textContent?.trim().length ?? 0;
189
+ let contentHtml: string | undefined;
190
+
191
+ if (bodyTextLength >= READABILITY_MIN_TEXT_CHARS) {
192
+ try {
193
+ // Readability mutates the document; fallbackHtml is captured above.
194
+ const article = new Readability(document).parse();
195
+ const articleTextLength = article?.textContent?.trim().length ?? 0;
196
+ if (
197
+ article?.content !== undefined &&
198
+ article.content !== null &&
199
+ articleTextLength >= bodyTextLength * READABILITY_MIN_KEEP_RATIO
200
+ ) {
201
+ contentHtml = article.content;
202
+ title = article.title?.trim() || title;
203
+ }
204
+ } catch {
205
+ // Readability can fail on non-article pages; fall back to the full body.
206
+ }
207
+ }
208
+
209
+ contentHtml ??= fallbackHtml;
210
+
211
+ const turndown = new TurndownService({
212
+ headingStyle: "atx",
213
+ codeBlockStyle: "fenced",
214
+ });
215
+
216
+ return { title, markdown: turndown.turndown(contentHtml).trim() };
217
+ }
218
+
219
+ // Pages like mp.weixin.qq.com ship the article hidden behind
220
+ // visibility:hidden/opacity:0 until their JavaScript reveals it. A static
221
+ // fetch never runs that JavaScript, so undo the hiding before extraction.
222
+ function revealJsHiddenContent(document: Document): void {
223
+ for (const node of document.querySelectorAll(
224
+ '[style*="visibility"], [style*="opacity"]',
225
+ )) {
226
+ const style = node.getAttribute("style");
227
+ if (style === null) {
228
+ continue;
229
+ }
230
+
231
+ const revealed = style.replace(
232
+ /(?:visibility\s*:\s*hidden|opacity\s*:\s*0(?![.\d]))\s*;?/gi,
233
+ "",
234
+ );
235
+ if (revealed !== style) {
236
+ node.setAttribute("style", revealed);
237
+ }
238
+ }
239
+ }
240
+
241
+ function prettyPrintJson(body: string): string {
242
+ try {
243
+ return JSON.stringify(JSON.parse(body), null, 2);
244
+ } catch {
245
+ return body;
246
+ }
247
+ }
248
+
249
+ function mimeType(contentType: string | null): string {
250
+ return (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
251
+ }
252
+
253
+ function isRedirectStatus(status: number): boolean {
254
+ return [301, 302, 303, 307, 308].includes(status);
255
+ }
256
+
257
+ function requestErrorMessage(error: unknown, timeoutMs: number): string {
258
+ if (error instanceof Error && error.name === "TimeoutError") {
259
+ return `Request timed out after ${timeoutMs}ms.`;
260
+ }
261
+
262
+ return `Request failed: ${errorMessage(error)}`;
263
+ }
264
+
265
+ function errorMessage(error: unknown): string {
266
+ return error instanceof Error ? error.message : String(error);
267
+ }
@@ -0,0 +1,78 @@
1
+ import type { ModelClient } from "../../model/model-client";
2
+ import type { ModelContextBudget } from "../../model/model-context-profile";
3
+ import { ContextMeter } from "../../agent/context-meter";
4
+ import type { ToolExecutionContext } from "../types";
5
+
6
+ export type Refiner = {
7
+ refine(
8
+ input: { url: string; prompt: string; content: string },
9
+ context: ToolExecutionContext,
10
+ ): Promise<string>;
11
+ };
12
+
13
+ const REFINE_SYSTEM_PROMPT = [
14
+ "You extract information from web page content for a coding agent.",
15
+ "Answer the prompt using only the provided page content.",
16
+ "Be concise. Quote relevant code snippets, URLs, and facts verbatim when useful.",
17
+ "If the content does not contain the requested information, say so explicitly.",
18
+ ].join("\n");
19
+
20
+ const DEFAULT_MAX_CONTENT_CHARS = 50_000;
21
+
22
+ export function createModelRefiner(options: {
23
+ createModelClient: () => ModelClient;
24
+ contextBudget: ModelContextBudget;
25
+ maxContentChars?: number;
26
+ }): Refiner {
27
+ const maxContentChars = options.maxContentChars ?? DEFAULT_MAX_CONTENT_CHARS;
28
+ let client: ModelClient | undefined;
29
+ const meter = new ContextMeter(options.contextBudget, { enableAnchor: false });
30
+
31
+ return {
32
+ async refine(input, context) {
33
+ client ??= options.createModelClient();
34
+
35
+ const truncated = input.content.length > maxContentChars;
36
+ const content = truncated
37
+ ? input.content.slice(0, maxContentChars)
38
+ : input.content;
39
+
40
+ const prepared = client.prepare({
41
+ messages: [
42
+ { role: "system", content: REFINE_SYSTEM_PROMPT },
43
+ {
44
+ role: "user",
45
+ content: [
46
+ `Web page: ${input.url}`,
47
+ truncated
48
+ ? `Page content (markdown, truncated to ${maxContentChars} characters):`
49
+ : "Page content (markdown):",
50
+ "",
51
+ content,
52
+ "",
53
+ "---",
54
+ "",
55
+ `Prompt: ${input.prompt}`,
56
+ ].join("\n"),
57
+ },
58
+ ],
59
+ tools: [],
60
+ });
61
+ const preflight = meter.measure(prepared);
62
+ meter.assertWithinBudget(preflight);
63
+ const output = await client.request(prepared, { signal: context.signal });
64
+ meter.recordProviderUsage(prepared, output);
65
+
66
+ const message = output.message;
67
+ if (
68
+ message.role !== "assistant" ||
69
+ typeof message.content !== "string" ||
70
+ message.content.trim() === ""
71
+ ) {
72
+ throw new Error("Refine model returned no text content.");
73
+ }
74
+
75
+ return message.content;
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,95 @@
1
+ import type { WebFetchRoute } from "./backend";
2
+
3
+ const privateHostnameSuffixes = [".localhost", ".local", ".internal"];
4
+
5
+ // Public hosts that the Exa backend handles poorly; always fetch them locally.
6
+ const forcedLocalHostnames = new Set(["mp.weixin.qq.com"]);
7
+
8
+ // Hosts that need JavaScript rendering; go straight to the headless browser.
9
+ const forcedBrowserHostnames = new Set<string>([]);
10
+
11
+ export type RouteContext = {
12
+ hasExaBackend: boolean;
13
+ hasBrowserBackend: boolean;
14
+ };
15
+
16
+ export function decideRoute(url: URL, context: RouteContext): WebFetchRoute {
17
+ if (
18
+ context.hasBrowserBackend &&
19
+ forcedBrowserHostnames.has(url.hostname.toLowerCase())
20
+ ) {
21
+ return "local-browser";
22
+ }
23
+
24
+ if (isPrivateHost(url.hostname)) {
25
+ return "local";
26
+ }
27
+
28
+ if (forcedLocalHostnames.has(url.hostname.toLowerCase())) {
29
+ return "local";
30
+ }
31
+
32
+ return context.hasExaBackend ? "exa" : "local";
33
+ }
34
+
35
+ // Escalate a static-fetch miss to the headless browser: the page either
36
+ // could not be fetched (without a definitive HTTP status the browser would
37
+ // also receive) or rendered to nothing without JavaScript.
38
+ export function shouldEscalateToBrowser(result: {
39
+ ok: boolean;
40
+ redirectUrl?: string;
41
+ markdown?: string;
42
+ httpStatusCode?: number;
43
+ }): boolean {
44
+ if (result.redirectUrl !== undefined) {
45
+ return false;
46
+ }
47
+
48
+ if (!result.ok) {
49
+ return result.httpStatusCode === undefined;
50
+ }
51
+
52
+ return (result.markdown ?? "").trim() === "";
53
+ }
54
+
55
+ export function isPrivateHost(hostname: string): boolean {
56
+ const normalized = hostname.toLowerCase();
57
+
58
+ if (normalized === "localhost" || normalized === "::1" || normalized === "0.0.0.0") {
59
+ return true;
60
+ }
61
+
62
+ if (privateHostnameSuffixes.some((suffix) => normalized.endsWith(suffix))) {
63
+ return true;
64
+ }
65
+
66
+ const octets = parseIpv4(normalized);
67
+ if (octets === undefined) {
68
+ return false;
69
+ }
70
+
71
+ const [a, b] = octets;
72
+ return (
73
+ a === 127 ||
74
+ a === 10 ||
75
+ (a === 172 && b >= 16 && b <= 31) ||
76
+ (a === 192 && b === 168) ||
77
+ (a === 169 && b === 254)
78
+ );
79
+ }
80
+
81
+ function parseIpv4(hostname: string): [number, number, number, number] | undefined {
82
+ const parts = hostname.split(".");
83
+ if (parts.length !== 4) {
84
+ return undefined;
85
+ }
86
+
87
+ const octets = parts.map((part) =>
88
+ /^\d{1,3}$/.test(part) ? Number(part) : Number.NaN,
89
+ );
90
+ if (octets.some((octet) => Number.isNaN(octet) || octet > 255)) {
91
+ return undefined;
92
+ }
93
+
94
+ return octets as [number, number, number, number];
95
+ }