tinker-agent 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -1
- package/README.md +217 -72
- package/bin/tinker.js +75 -25
- package/package.json +8 -3
- package/src/agent/loop.ts +115 -11
- package/src/agent/runtime-session.ts +34 -15
- package/src/cli/command-line.ts +291 -0
- package/src/cli/config.ts +131 -264
- package/src/cli/index.ts +33 -21
- package/src/cli/main.ts +213 -0
- package/src/cli/model-profiles.ts +143 -72
- package/src/cli/output.ts +113 -0
- package/src/cli/package-metadata.ts +36 -0
- package/src/cli/prompt-source.ts +229 -0
- package/src/cli/public-cli-contract.ts +69 -0
- package/src/cli/public-config-contract.ts +650 -0
- package/src/cli/run-runner.ts +17 -12
- package/src/cli/runner-dependencies.ts +100 -0
- package/src/cli/tui-runner.tsx +52 -49
- package/src/events/observation-text-log.ts +2 -0
- package/src/events/stdout-event-printer.ts +7 -1
- package/src/events/types.ts +27 -3
- package/src/mcp/mcp-manager.ts +2 -19
- package/src/mcp/mcp-tool-executor.ts +3 -4
- package/src/model/model-client.ts +29 -0
- package/src/model/model-context-profile.ts +0 -30
- package/src/model/openai-chat-mapping.ts +54 -15
- package/src/model/openai-chat-model-client.ts +46 -27
- package/src/model/openai-chat-stream.ts +6 -2
- package/src/tools/bash.ts +8 -25
- package/src/tools/grep.ts +9 -1
- package/src/tools/registry.ts +15 -1
- package/src/tools/ripgrep.ts +24 -27
- package/src/tools/web-fetch/index.ts +2 -15
- package/src/tui/app.tsx +3 -0
- package/src/tui/components/prompt-input.tsx +6 -3
- package/src/tui/event-store.ts +24 -7
- package/src/tui/slash-commands.ts +76 -24
- package/src/tui/workspace-file-search.ts +78 -71
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type { SessionId } from "../ids/runtime-id";
|
|
2
|
+
import { createUuidV7 } from "../ids/uuid-v7";
|
|
3
|
+
import { parseCommandLine, type CommandLineResult } from "./command-line";
|
|
4
|
+
import type {
|
|
5
|
+
ResolvedPublicConfig,
|
|
6
|
+
RunnerConfig,
|
|
7
|
+
RunnerConfigSelection,
|
|
8
|
+
} from "./config";
|
|
9
|
+
import {
|
|
10
|
+
CliUsageError,
|
|
11
|
+
flushCliOutput,
|
|
12
|
+
renderCliFailure,
|
|
13
|
+
renderUsageError,
|
|
14
|
+
writeCliOutput,
|
|
15
|
+
type CliOutputWriter,
|
|
16
|
+
} from "./output";
|
|
17
|
+
import { loadPackageMetadata, type PackageMetadata } from "./package-metadata";
|
|
18
|
+
import {
|
|
19
|
+
PromptInputError,
|
|
20
|
+
resolvePromptSource,
|
|
21
|
+
type PromptReadable,
|
|
22
|
+
type PromptSource,
|
|
23
|
+
type ResolvedPrompt,
|
|
24
|
+
} from "./prompt-source";
|
|
25
|
+
|
|
26
|
+
export const BOOTSTRAP_FAILURE_MESSAGE =
|
|
27
|
+
"Tinker failed to start. Reinstall tinker-agent.\n";
|
|
28
|
+
|
|
29
|
+
export type MainInput = {
|
|
30
|
+
readonly args: readonly string[];
|
|
31
|
+
readonly stdin: PromptReadable;
|
|
32
|
+
readonly stdout: CliOutputWriter;
|
|
33
|
+
readonly stderr: CliOutputWriter;
|
|
34
|
+
readonly cwd: string;
|
|
35
|
+
readonly env: NodeJS.ProcessEnv;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type ConfigBoundary = {
|
|
39
|
+
readonly resolvePublicConfig: (input: {
|
|
40
|
+
readonly env: NodeJS.ProcessEnv;
|
|
41
|
+
readonly cwd: string;
|
|
42
|
+
}) => Promise<ResolvedPublicConfig>;
|
|
43
|
+
readonly deriveRunnerConfig: (
|
|
44
|
+
snapshot: ResolvedPublicConfig,
|
|
45
|
+
selection: RunnerConfigSelection,
|
|
46
|
+
) => RunnerConfig;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type TuiRunner = {
|
|
50
|
+
readonly runTui: (options: {
|
|
51
|
+
readonly publicConfig: ResolvedPublicConfig;
|
|
52
|
+
readonly initialRunnerConfig: RunnerConfig;
|
|
53
|
+
readonly env: NodeJS.ProcessEnv;
|
|
54
|
+
}) => Promise<void>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type OneShotRunner = {
|
|
58
|
+
readonly runOneShot: (
|
|
59
|
+
prompt: string,
|
|
60
|
+
options: {
|
|
61
|
+
readonly config: RunnerConfig;
|
|
62
|
+
readonly tooling: ResolvedPublicConfig["tooling"];
|
|
63
|
+
readonly stdout: CliOutputWriter;
|
|
64
|
+
readonly stderr: CliOutputWriter;
|
|
65
|
+
readonly env: NodeJS.ProcessEnv;
|
|
66
|
+
},
|
|
67
|
+
) => Promise<number>;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export type MainDependencies = {
|
|
71
|
+
readonly loadPackageMetadata: () => Promise<PackageMetadata>;
|
|
72
|
+
readonly parseCommandLine: (
|
|
73
|
+
args: readonly string[],
|
|
74
|
+
packageVersion: string,
|
|
75
|
+
) => Promise<CommandLineResult>;
|
|
76
|
+
readonly loadConfigBoundary: () => Promise<ConfigBoundary>;
|
|
77
|
+
readonly createSessionId: () => SessionId;
|
|
78
|
+
readonly resolvePromptSource: (
|
|
79
|
+
source: PromptSource,
|
|
80
|
+
input: { readonly stdin: PromptReadable; readonly cwd: string },
|
|
81
|
+
) => Promise<ResolvedPrompt>;
|
|
82
|
+
readonly loadTuiRunner: () => Promise<TuiRunner>;
|
|
83
|
+
readonly loadOneShotRunner: () => Promise<OneShotRunner>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const DEFAULT_DEPENDENCIES: MainDependencies = {
|
|
87
|
+
loadPackageMetadata,
|
|
88
|
+
parseCommandLine,
|
|
89
|
+
loadConfigBoundary: () => import("./config"),
|
|
90
|
+
createSessionId: () => createUuidV7() as SessionId,
|
|
91
|
+
resolvePromptSource,
|
|
92
|
+
loadTuiRunner: () => import("./tui-runner"),
|
|
93
|
+
loadOneShotRunner: () => import("./run-runner"),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export async function main(
|
|
97
|
+
input: MainInput,
|
|
98
|
+
injected: Partial<MainDependencies> = {},
|
|
99
|
+
): Promise<number> {
|
|
100
|
+
const dependencies = { ...DEFAULT_DEPENDENCIES, ...injected };
|
|
101
|
+
const args = Object.freeze([...input.args]);
|
|
102
|
+
const env = Object.freeze({ ...input.env }) as NodeJS.ProcessEnv;
|
|
103
|
+
const cwd = input.cwd;
|
|
104
|
+
const finish = async (exitCode: number): Promise<number> => {
|
|
105
|
+
await flushCliOutput(input.stdout);
|
|
106
|
+
await flushCliOutput(input.stderr);
|
|
107
|
+
return exitCode;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
let metadata: PackageMetadata;
|
|
112
|
+
try {
|
|
113
|
+
metadata = await dependencies.loadPackageMetadata();
|
|
114
|
+
} catch {
|
|
115
|
+
await writeCliOutput(input.stderr, BOOTSTRAP_FAILURE_MESSAGE);
|
|
116
|
+
return finish(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let parsed: CommandLineResult;
|
|
120
|
+
try {
|
|
121
|
+
parsed = await dependencies.parseCommandLine(args, metadata.version);
|
|
122
|
+
} catch (error) {
|
|
123
|
+
if (error instanceof CliUsageError) {
|
|
124
|
+
await writeCliOutput(input.stderr, renderUsageError(error));
|
|
125
|
+
return finish(2);
|
|
126
|
+
}
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (parsed.type === "terminal") {
|
|
131
|
+
await writeCliOutput(input.stdout, parsed.stdout);
|
|
132
|
+
await writeCliOutput(input.stderr, parsed.stderr);
|
|
133
|
+
return finish(0);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let configBoundary: ConfigBoundary;
|
|
137
|
+
let publicConfig: ResolvedPublicConfig;
|
|
138
|
+
let runnerConfig: RunnerConfig;
|
|
139
|
+
try {
|
|
140
|
+
configBoundary = await dependencies.loadConfigBoundary();
|
|
141
|
+
publicConfig = await configBoundary.resolvePublicConfig({ env, cwd });
|
|
142
|
+
runnerConfig = configBoundary.deriveRunnerConfig(publicConfig, {
|
|
143
|
+
sessionId: dependencies.createSessionId(),
|
|
144
|
+
...(parsed.command.profileName === undefined
|
|
145
|
+
? {}
|
|
146
|
+
: { profileName: parsed.command.profileName }),
|
|
147
|
+
});
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await writeCliOutput(
|
|
150
|
+
input.stderr,
|
|
151
|
+
renderCliFailure("Configuration failed", error),
|
|
152
|
+
);
|
|
153
|
+
return finish(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (parsed.command.type === "tui") {
|
|
157
|
+
try {
|
|
158
|
+
const runner = await dependencies.loadTuiRunner();
|
|
159
|
+
await runner.runTui({
|
|
160
|
+
publicConfig,
|
|
161
|
+
initialRunnerConfig: runnerConfig,
|
|
162
|
+
env,
|
|
163
|
+
});
|
|
164
|
+
return finish(0);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
await writeCliOutput(input.stderr, renderCliFailure("Runtime failed", error));
|
|
167
|
+
return finish(1);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let prompt: ResolvedPrompt;
|
|
172
|
+
try {
|
|
173
|
+
prompt = await dependencies.resolvePromptSource(parsed.command.promptSource, {
|
|
174
|
+
stdin: input.stdin,
|
|
175
|
+
cwd,
|
|
176
|
+
});
|
|
177
|
+
} catch (error) {
|
|
178
|
+
if (error instanceof PromptInputError) {
|
|
179
|
+
await writeCliOutput(
|
|
180
|
+
input.stderr,
|
|
181
|
+
renderCliFailure("Prompt input failed", error),
|
|
182
|
+
);
|
|
183
|
+
return finish(error.exitCode);
|
|
184
|
+
}
|
|
185
|
+
await writeCliOutput(
|
|
186
|
+
input.stderr,
|
|
187
|
+
renderCliFailure("Prompt input failed", error),
|
|
188
|
+
);
|
|
189
|
+
return finish(1);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const runner = await dependencies.loadOneShotRunner();
|
|
194
|
+
const exitCode = await runner.runOneShot(prompt.text, {
|
|
195
|
+
config: runnerConfig,
|
|
196
|
+
tooling: publicConfig.tooling,
|
|
197
|
+
stdout: input.stdout,
|
|
198
|
+
stderr: input.stderr,
|
|
199
|
+
env,
|
|
200
|
+
});
|
|
201
|
+
return finish(exitCode);
|
|
202
|
+
} catch (error) {
|
|
203
|
+
await writeCliOutput(input.stderr, renderCliFailure("Runtime failed", error));
|
|
204
|
+
return finish(1);
|
|
205
|
+
}
|
|
206
|
+
} catch (error) {
|
|
207
|
+
await writeCliOutput(
|
|
208
|
+
input.stderr,
|
|
209
|
+
renderCliFailure("Tinker failed unexpectedly", error),
|
|
210
|
+
);
|
|
211
|
+
return finish(1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
1
|
import { readFile, rename, unlink, writeFile } from "node:fs/promises";
|
|
3
2
|
import {
|
|
4
3
|
createModelContextProfile,
|
|
5
4
|
type ModelContextProfile,
|
|
6
5
|
} from "../model/model-context-profile";
|
|
6
|
+
import {
|
|
7
|
+
MODEL_PROFILE_FIELDS,
|
|
8
|
+
MODEL_PROFILES_DOCUMENT_FIELDS,
|
|
9
|
+
MODEL_TOKEN_ESTIMATOR_FIELDS,
|
|
10
|
+
type ModelTokenEstimatorKind,
|
|
11
|
+
type ModelTokenEstimatorMaxRetries,
|
|
12
|
+
} from "./public-config-contract";
|
|
7
13
|
|
|
8
14
|
export type ModelProfile = {
|
|
9
15
|
readonly name: string;
|
|
@@ -21,12 +27,12 @@ export type ModelProfile = {
|
|
|
21
27
|
export type ModelInputModality = "text" | "image";
|
|
22
28
|
|
|
23
29
|
export type ModelTokenEstimatorProfile = {
|
|
24
|
-
readonly kind:
|
|
30
|
+
readonly kind: ModelTokenEstimatorKind;
|
|
25
31
|
readonly model: string;
|
|
26
32
|
readonly apiBase: string;
|
|
27
33
|
readonly apiKey: string;
|
|
28
34
|
readonly timeoutMs: number;
|
|
29
|
-
readonly maxRetries:
|
|
35
|
+
readonly maxRetries: ModelTokenEstimatorMaxRetries;
|
|
30
36
|
};
|
|
31
37
|
|
|
32
38
|
export type ModelProfiles = {
|
|
@@ -34,24 +40,7 @@ export type ModelProfiles = {
|
|
|
34
40
|
readonly profiles: ReadonlyMap<string, ModelProfile>;
|
|
35
41
|
};
|
|
36
42
|
|
|
37
|
-
export function
|
|
38
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
39
|
-
): string | undefined {
|
|
40
|
-
const value = env.TINKER_MODELS;
|
|
41
|
-
if (value === undefined || value.trim() === "") {
|
|
42
|
-
return undefined;
|
|
43
|
-
}
|
|
44
|
-
return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function loadModelProfiles(
|
|
48
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
49
|
-
): Promise<ModelProfiles | undefined> {
|
|
50
|
-
const configPath = modelsConfigPath(env);
|
|
51
|
-
if (configPath === undefined) {
|
|
52
|
-
return undefined;
|
|
53
|
-
}
|
|
54
|
-
|
|
43
|
+
export async function loadModelProfiles(configPath: string): Promise<ModelProfiles> {
|
|
55
44
|
let raw: string;
|
|
56
45
|
try {
|
|
57
46
|
raw = await readFile(configPath, "utf8");
|
|
@@ -67,13 +56,8 @@ export async function loadModelProfiles(
|
|
|
67
56
|
|
|
68
57
|
export async function persistDefaultProfile(
|
|
69
58
|
profileName: string,
|
|
70
|
-
|
|
59
|
+
configPath: string,
|
|
71
60
|
): Promise<void> {
|
|
72
|
-
const configPath = modelsConfigPath(env);
|
|
73
|
-
if (configPath === undefined) {
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
61
|
let raw: string;
|
|
78
62
|
try {
|
|
79
63
|
raw = await readFile(configPath, "utf8");
|
|
@@ -128,6 +112,11 @@ export function parseModelProfiles(raw: string, sourcePath: string): ModelProfil
|
|
|
128
112
|
if (!isRecord(json)) {
|
|
129
113
|
throw new Error(`Model profiles ${sourcePath} must be a JSON object.`);
|
|
130
114
|
}
|
|
115
|
+
assertKnownKeys(
|
|
116
|
+
json,
|
|
117
|
+
MODEL_PROFILES_DOCUMENT_FIELDS.map((field) => field.name),
|
|
118
|
+
`Model profiles ${sourcePath}`,
|
|
119
|
+
);
|
|
131
120
|
|
|
132
121
|
const defaultProfile = json.default;
|
|
133
122
|
if (typeof defaultProfile !== "string" || defaultProfile.trim() === "") {
|
|
@@ -238,45 +227,31 @@ function parseProfile(
|
|
|
238
227
|
|
|
239
228
|
assertKnownKeys(
|
|
240
229
|
value,
|
|
241
|
-
|
|
242
|
-
"model",
|
|
243
|
-
"apiBase",
|
|
244
|
-
"apiKey",
|
|
245
|
-
"contextWindowTokens",
|
|
246
|
-
"maxSupportedOutputTokens",
|
|
247
|
-
"includeReasoningContent",
|
|
248
|
-
"stream",
|
|
249
|
-
"inputModalities",
|
|
250
|
-
"tokenEstimator",
|
|
251
|
-
],
|
|
230
|
+
MODEL_PROFILE_FIELDS.map((field) => field.name),
|
|
252
231
|
where,
|
|
253
232
|
);
|
|
254
233
|
|
|
255
|
-
const model =
|
|
256
|
-
const apiBase =
|
|
257
|
-
const apiKey =
|
|
234
|
+
const model = parseProfileString(value, "model", where);
|
|
235
|
+
const apiBase = parseProfileString(value, "apiBase", where);
|
|
236
|
+
const apiKey = parseProfileString(value, "apiKey", where);
|
|
258
237
|
|
|
259
|
-
const contextWindowTokens =
|
|
260
|
-
value
|
|
261
|
-
|
|
238
|
+
const contextWindowTokens = parseProfilePositiveInteger(
|
|
239
|
+
value,
|
|
240
|
+
"contextWindowTokens",
|
|
241
|
+
where,
|
|
262
242
|
);
|
|
263
|
-
const maxSupportedOutputTokens =
|
|
264
|
-
value
|
|
265
|
-
|
|
243
|
+
const maxSupportedOutputTokens = parseProfilePositiveInteger(
|
|
244
|
+
value,
|
|
245
|
+
"maxSupportedOutputTokens",
|
|
246
|
+
where,
|
|
266
247
|
);
|
|
267
248
|
|
|
268
|
-
const includeReasoningContent =
|
|
269
|
-
value
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
);
|
|
275
|
-
|
|
276
|
-
const stream =
|
|
277
|
-
value.stream === undefined
|
|
278
|
-
? true
|
|
279
|
-
: parseBoolean(value.stream, `${where}: "stream"`);
|
|
249
|
+
const includeReasoningContent = parseProfileBoolean(
|
|
250
|
+
value,
|
|
251
|
+
"includeReasoningContent",
|
|
252
|
+
where,
|
|
253
|
+
);
|
|
254
|
+
const stream = parseProfileBoolean(value, "stream", where);
|
|
280
255
|
|
|
281
256
|
const inputModalities = parseInputModalities(
|
|
282
257
|
value.inputModalities,
|
|
@@ -316,7 +291,8 @@ function parseInputModalities(
|
|
|
316
291
|
name: string,
|
|
317
292
|
): readonly ModelInputModality[] {
|
|
318
293
|
if (value === undefined) {
|
|
319
|
-
|
|
294
|
+
const defaultValue = modelProfileField("inputModalities").defaultValue;
|
|
295
|
+
return defaultValue;
|
|
320
296
|
}
|
|
321
297
|
if (!Array.isArray(value) || value.length === 0) {
|
|
322
298
|
throw new Error(`${name} must be a non-empty array.`);
|
|
@@ -345,32 +321,127 @@ function parseTokenEstimator(value: unknown, name: string): ModelTokenEstimatorP
|
|
|
345
321
|
}
|
|
346
322
|
assertKnownKeys(
|
|
347
323
|
value,
|
|
348
|
-
|
|
324
|
+
MODEL_TOKEN_ESTIMATOR_FIELDS.map((field) => field.name),
|
|
349
325
|
name,
|
|
350
326
|
);
|
|
351
|
-
|
|
352
|
-
|
|
327
|
+
const kindField = tokenEstimatorField("kind");
|
|
328
|
+
if (value.kind !== kindField.literalValue) {
|
|
329
|
+
throw new Error(`${name}.kind must be ${JSON.stringify(kindField.literalValue)}.`);
|
|
330
|
+
}
|
|
331
|
+
const model = parseTokenEstimatorString(value, "model", name);
|
|
332
|
+
const apiBase = parseTokenEstimatorString(value, "apiBase", name);
|
|
333
|
+
const apiKey = parseTokenEstimatorString(value, "apiKey", name);
|
|
334
|
+
const timeoutField = tokenEstimatorField("timeoutMs");
|
|
335
|
+
if (timeoutField.valueKind !== "positive-integer") {
|
|
336
|
+
throw new Error("Token estimator timeoutMs contract kind is invalid.");
|
|
353
337
|
}
|
|
354
|
-
const model = requireString(value.model, `${name}.model`);
|
|
355
|
-
const apiBase = requireString(value.apiBase, `${name}.apiBase`);
|
|
356
|
-
const apiKey = requireString(value.apiKey, `${name}.apiKey`);
|
|
357
338
|
const timeoutMs = requirePositiveInteger(value.timeoutMs, `${name}.timeoutMs`);
|
|
358
|
-
if (
|
|
359
|
-
|
|
339
|
+
if (
|
|
340
|
+
timeoutField.minimum === undefined ||
|
|
341
|
+
timeoutField.maximum === undefined ||
|
|
342
|
+
timeoutMs < timeoutField.minimum ||
|
|
343
|
+
timeoutMs > timeoutField.maximum
|
|
344
|
+
) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`${name}.timeoutMs must be between ${timeoutField.minimum} and ${timeoutField.maximum}.`,
|
|
347
|
+
);
|
|
360
348
|
}
|
|
361
|
-
|
|
362
|
-
|
|
349
|
+
const maxRetriesField = tokenEstimatorField("maxRetries");
|
|
350
|
+
if (value.maxRetries !== maxRetriesField.literalValue) {
|
|
351
|
+
throw new Error(`${name}.maxRetries must be ${maxRetriesField.literalValue}.`);
|
|
363
352
|
}
|
|
364
353
|
return Object.freeze({
|
|
365
|
-
kind:
|
|
354
|
+
kind: kindField.literalValue,
|
|
366
355
|
model,
|
|
367
356
|
apiBase,
|
|
368
357
|
apiKey,
|
|
369
358
|
timeoutMs,
|
|
370
|
-
maxRetries:
|
|
359
|
+
maxRetries: maxRetriesField.literalValue,
|
|
371
360
|
});
|
|
372
361
|
}
|
|
373
362
|
|
|
363
|
+
type ModelProfileFieldName = (typeof MODEL_PROFILE_FIELDS)[number]["name"];
|
|
364
|
+
type ModelTokenEstimatorFieldName =
|
|
365
|
+
(typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number]["name"];
|
|
366
|
+
|
|
367
|
+
function modelProfileField<Name extends ModelProfileFieldName>(
|
|
368
|
+
name: Name,
|
|
369
|
+
): Extract<(typeof MODEL_PROFILE_FIELDS)[number], { readonly name: Name }> {
|
|
370
|
+
const field = MODEL_PROFILE_FIELDS.find((candidate) => candidate.name === name);
|
|
371
|
+
if (field === undefined) {
|
|
372
|
+
throw new Error(`Missing model profile field contract for ${name}.`);
|
|
373
|
+
}
|
|
374
|
+
return field as Extract<
|
|
375
|
+
(typeof MODEL_PROFILE_FIELDS)[number],
|
|
376
|
+
{ readonly name: Name }
|
|
377
|
+
>;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function tokenEstimatorField<Name extends ModelTokenEstimatorFieldName>(
|
|
381
|
+
name: Name,
|
|
382
|
+
): Extract<(typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number], { readonly name: Name }> {
|
|
383
|
+
const field = MODEL_TOKEN_ESTIMATOR_FIELDS.find(
|
|
384
|
+
(candidate) => candidate.name === name,
|
|
385
|
+
);
|
|
386
|
+
if (field === undefined) {
|
|
387
|
+
throw new Error(`Missing token estimator field contract for ${name}.`);
|
|
388
|
+
}
|
|
389
|
+
return field as Extract<
|
|
390
|
+
(typeof MODEL_TOKEN_ESTIMATOR_FIELDS)[number],
|
|
391
|
+
{ readonly name: Name }
|
|
392
|
+
>;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function parseProfileString(
|
|
396
|
+
value: Record<string, unknown>,
|
|
397
|
+
name: "model" | "apiBase" | "apiKey",
|
|
398
|
+
where: string,
|
|
399
|
+
): string {
|
|
400
|
+
const field = modelProfileField(name);
|
|
401
|
+
if (field.valueKind !== "non-empty-string") {
|
|
402
|
+
throw new Error(`Model profile field ${name} has an invalid contract kind.`);
|
|
403
|
+
}
|
|
404
|
+
return requireString(value[name], `${where}: ${JSON.stringify(name)}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function parseProfilePositiveInteger(
|
|
408
|
+
value: Record<string, unknown>,
|
|
409
|
+
name: "contextWindowTokens" | "maxSupportedOutputTokens",
|
|
410
|
+
where: string,
|
|
411
|
+
): number {
|
|
412
|
+
const field = modelProfileField(name);
|
|
413
|
+
if (field.valueKind !== "positive-integer") {
|
|
414
|
+
throw new Error(`Model profile field ${name} has an invalid contract kind.`);
|
|
415
|
+
}
|
|
416
|
+
return requirePositiveInteger(value[name], `${where}: ${JSON.stringify(name)}`);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function parseProfileBoolean(
|
|
420
|
+
value: Record<string, unknown>,
|
|
421
|
+
name: "includeReasoningContent" | "stream",
|
|
422
|
+
where: string,
|
|
423
|
+
): boolean {
|
|
424
|
+
const field = modelProfileField(name);
|
|
425
|
+
if (field.valueKind !== "boolean" || typeof field.defaultValue !== "boolean") {
|
|
426
|
+
throw new Error(`Model profile field ${name} has an invalid boolean contract.`);
|
|
427
|
+
}
|
|
428
|
+
return value[name] === undefined
|
|
429
|
+
? field.defaultValue
|
|
430
|
+
: parseBoolean(value[name], `${where}: ${JSON.stringify(name)}`);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function parseTokenEstimatorString(
|
|
434
|
+
value: Record<string, unknown>,
|
|
435
|
+
name: "model" | "apiBase" | "apiKey",
|
|
436
|
+
where: string,
|
|
437
|
+
): string {
|
|
438
|
+
const field = tokenEstimatorField(name);
|
|
439
|
+
if (field.valueKind !== "non-empty-string") {
|
|
440
|
+
throw new Error(`Token estimator field ${name} has an invalid contract kind.`);
|
|
441
|
+
}
|
|
442
|
+
return requireString(value[name], `${where}.${name}`);
|
|
443
|
+
}
|
|
444
|
+
|
|
374
445
|
function assertKnownKeys(
|
|
375
446
|
value: Record<string, unknown>,
|
|
376
447
|
allowed: readonly string[],
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
export const MAX_CLI_DIAGNOSTIC_DETAIL_BYTES = 512;
|
|
2
|
+
|
|
3
|
+
const TRUNCATION_MARKER = "...[truncated]";
|
|
4
|
+
const ESCAPE = String.fromCharCode(27);
|
|
5
|
+
const ANSI_CSI_PATTERN = new RegExp(`${ESCAPE}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|
6
|
+
|
|
7
|
+
export type CliCommandScope = "root" | "run";
|
|
8
|
+
|
|
9
|
+
export interface CliOutputWriter {
|
|
10
|
+
write(chunk: string): boolean | void;
|
|
11
|
+
readonly writableNeedDrain?: boolean;
|
|
12
|
+
once?(event: "drain", listener: () => void): unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class CliUsageError extends Error {
|
|
16
|
+
constructor(
|
|
17
|
+
message: string,
|
|
18
|
+
readonly scope: CliCommandScope,
|
|
19
|
+
) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.name = "CliUsageError";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function renderUsageError(error: CliUsageError): string {
|
|
26
|
+
const hint =
|
|
27
|
+
error.scope === "run"
|
|
28
|
+
? 'Run "tinker run --help" for usage.'
|
|
29
|
+
: 'Run "tinker --help" for usage.';
|
|
30
|
+
return `error: ${sanitizeDiagnosticDetail(error.message)}\n${hint}\n`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function renderCliFailure(label: string, error: unknown): string {
|
|
34
|
+
return `${label}: ${sanitizeDiagnosticDetail(errorMessage(error))}\n`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function sanitizeDiagnosticDetail(detail: string): string {
|
|
38
|
+
const withoutAnsi = detail.replaceAll(ANSI_CSI_PATTERN, "");
|
|
39
|
+
let escaped = "";
|
|
40
|
+
for (const character of withoutAnsi) {
|
|
41
|
+
const codePoint = character.codePointAt(0);
|
|
42
|
+
if (codePoint === undefined) {
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (character === "\n") {
|
|
46
|
+
escaped += "\\n";
|
|
47
|
+
} else if (character === "\r") {
|
|
48
|
+
escaped += "\\r";
|
|
49
|
+
} else if (character === "\t") {
|
|
50
|
+
escaped += "\\t";
|
|
51
|
+
} else if (codePoint < 0x20 || (codePoint >= 0x7f && codePoint <= 0x9f)) {
|
|
52
|
+
escaped += `\\u${codePoint.toString(16).padStart(4, "0")}`;
|
|
53
|
+
} else {
|
|
54
|
+
escaped += character;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return truncateUtf8(escaped, MAX_CLI_DIAGNOSTIC_DETAIL_BYTES);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatDiagnosticPath(filePath: string): string {
|
|
61
|
+
return sanitizeDiagnosticDetail(JSON.stringify(filePath));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function writeCliOutput(
|
|
65
|
+
writer: CliOutputWriter,
|
|
66
|
+
output: string,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
if (output === "") {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const accepted = writer.write(output);
|
|
72
|
+
if (accepted === false) {
|
|
73
|
+
await waitForDrain(writer);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function flushCliOutput(writer: CliOutputWriter): Promise<void> {
|
|
78
|
+
if (writer.writableNeedDrain === true) {
|
|
79
|
+
await waitForDrain(writer);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function truncateUtf8(value: string, maxBytes: number): string {
|
|
84
|
+
if (Buffer.byteLength(value) <= maxBytes) {
|
|
85
|
+
return value;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const contentLimit = maxBytes - Buffer.byteLength(TRUNCATION_MARKER);
|
|
89
|
+
let result = "";
|
|
90
|
+
let byteLength = 0;
|
|
91
|
+
for (const character of value) {
|
|
92
|
+
const characterBytes = Buffer.byteLength(character);
|
|
93
|
+
if (byteLength + characterBytes > contentLimit) {
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
result += character;
|
|
97
|
+
byteLength += characterBytes;
|
|
98
|
+
}
|
|
99
|
+
return result + TRUNCATION_MARKER;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function waitForDrain(writer: CliOutputWriter): Promise<void> {
|
|
103
|
+
if (writer.once === undefined) {
|
|
104
|
+
return Promise.resolve();
|
|
105
|
+
}
|
|
106
|
+
return new Promise((resolve) => {
|
|
107
|
+
writer.once?.("drain", resolve);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function errorMessage(error: unknown): string {
|
|
112
|
+
return error instanceof Error ? error.message : String(error);
|
|
113
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
|
|
3
|
+
export type PackageMetadata = {
|
|
4
|
+
readonly name: string;
|
|
5
|
+
readonly version: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export class PackageMetadataError extends Error {
|
|
9
|
+
constructor() {
|
|
10
|
+
super("Tinker package metadata is unavailable.");
|
|
11
|
+
this.name = "PackageMetadataError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function loadPackageMetadata(
|
|
16
|
+
packageJsonUrl = new URL("../../package.json", import.meta.url),
|
|
17
|
+
): Promise<PackageMetadata> {
|
|
18
|
+
try {
|
|
19
|
+
const parsed = JSON.parse(await readFile(packageJsonUrl, "utf8")) as unknown;
|
|
20
|
+
if (
|
|
21
|
+
typeof parsed !== "object" ||
|
|
22
|
+
parsed === null ||
|
|
23
|
+
!("name" in parsed) ||
|
|
24
|
+
typeof parsed.name !== "string" ||
|
|
25
|
+
parsed.name.trim() === "" ||
|
|
26
|
+
!("version" in parsed) ||
|
|
27
|
+
typeof parsed.version !== "string" ||
|
|
28
|
+
parsed.version.trim() === ""
|
|
29
|
+
) {
|
|
30
|
+
throw new TypeError("Invalid package metadata.");
|
|
31
|
+
}
|
|
32
|
+
return Object.freeze({ name: parsed.name, version: parsed.version });
|
|
33
|
+
} catch {
|
|
34
|
+
throw new PackageMetadataError();
|
|
35
|
+
}
|
|
36
|
+
}
|