tinker-agent 1.3.0 → 1.5.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +271 -72
  3. package/bin/tinker.js +75 -25
  4. package/package.json +12 -3
  5. package/src/agent/runtime-session.ts +113 -15
  6. package/src/cli/command-line.ts +291 -0
  7. package/src/cli/config.ts +158 -262
  8. package/src/cli/index.ts +33 -21
  9. package/src/cli/main.ts +213 -0
  10. package/src/cli/model-profiles.ts +226 -72
  11. package/src/cli/output.ts +113 -0
  12. package/src/cli/package-metadata.ts +36 -0
  13. package/src/cli/prompt-source.ts +229 -0
  14. package/src/cli/public-cli-contract.ts +69 -0
  15. package/src/cli/public-config-contract.ts +732 -0
  16. package/src/cli/run-runner.ts +17 -12
  17. package/src/cli/runner-dependencies.ts +108 -0
  18. package/src/cli/tui-memory.ts +67 -0
  19. package/src/cli/tui-runner.tsx +79 -49
  20. package/src/context/context-policy.ts +2 -2
  21. package/src/events/stdout-event-printer.ts +1 -0
  22. package/src/mcp/mcp-manager.ts +2 -19
  23. package/src/mcp/mcp-tool-executor.ts +3 -4
  24. package/src/memory/contracts.ts +148 -0
  25. package/src/memory/embedding-client.ts +105 -0
  26. package/src/memory/memory-coordinator.ts +556 -0
  27. package/src/memory/memory-extractor.ts +231 -0
  28. package/src/memory/memory-log.ts +88 -0
  29. package/src/memory/memory-search-tool.ts +100 -0
  30. package/src/memory/memory-store.ts +687 -0
  31. package/src/memory/vector.ts +153 -0
  32. package/src/model/fake-model-client.ts +971 -3
  33. package/src/model/model-context-profile.ts +0 -30
  34. package/src/observation/observation-builder.ts +20 -0
  35. package/src/session/session-store.ts +123 -0
  36. package/src/tools/bash.ts +8 -25
  37. package/src/tools/grep.ts +9 -1
  38. package/src/tools/registry.ts +19 -1
  39. package/src/tools/ripgrep.ts +24 -27
  40. package/src/tools/types.ts +16 -0
  41. package/src/tools/web-fetch/index.ts +2 -15
  42. package/src/tui/app.tsx +72 -2
  43. package/src/tui/clipboard.ts +22 -0
  44. package/src/tui/components/footer.tsx +9 -4
  45. package/src/tui/components/memory-browser.tsx +151 -0
  46. package/src/tui/components/prompt-input.tsx +6 -3
  47. package/src/tui/event-store.ts +9 -2
  48. package/src/tui/slash-commands.ts +88 -24
  49. package/src/tui/workspace-file-search.ts +78 -71
@@ -13,6 +13,7 @@ import {
13
13
  runtimeIdFactory,
14
14
  type RuntimeIdFactory,
15
15
  type SessionId,
16
+ type TurnId,
16
17
  } from "../ids/runtime-id";
17
18
  import { loadMcpConfig } from "../mcp/mcp-config";
18
19
  import {
@@ -71,11 +72,13 @@ import {
71
72
  renderedMessageHash,
72
73
  } from "../context/compiled-context-hash";
73
74
  import { createDefaultTooling, type DefaultTooling } from "../tools/registry";
75
+ import type { ToolExecutor } from "../tools/types";
74
76
  import type { Refiner } from "../tools/web-fetch/refiner";
75
77
  import type { ProjectInstructionManifest } from "../instructions/project-instructions";
76
78
  import {
77
79
  SessionStore,
78
80
  createSessionCompatibilityContract,
81
+ type CompletedTurnSnapshot,
79
82
  type SessionRecoveryResult,
80
83
  type StoredSkillActivation,
81
84
  } from "../session/session-store";
@@ -92,6 +95,7 @@ import { TurnCancelledError } from "./turn-cancellation";
92
95
  import type { ToolCompletionInput } from "../context/protocol-frame";
93
96
  import type { BuiltContextRequest } from "../context/context-revision";
94
97
  import type { CommittedToolCompletion } from "./session-ledger";
98
+ import type { PublicToolingConfig } from "../cli/public-config-contract";
95
99
  import type {
96
100
  IterationIdentity,
97
101
  RunAgentResult,
@@ -211,6 +215,25 @@ export type SkillsUpdateSummary = {
211
215
  readonly addedOverrideCount: number;
212
216
  };
213
217
 
218
+ export type CompletedTurnHookInput = {
219
+ readonly workspaceRoot: string;
220
+ readonly sessionId: SessionId;
221
+ readonly turnId: TurnId;
222
+ readonly snapshot: CompletedTurnSnapshot;
223
+ };
224
+
225
+ export type CompletedTurnHookFailure = {
226
+ readonly workspaceRoot: string;
227
+ readonly sessionId: SessionId;
228
+ readonly turnId: TurnId;
229
+ readonly reason: "completed_turn_snapshot_failed" | "completed_turn_enqueue_failed";
230
+ };
231
+
232
+ export type CompletedTurnHook = {
233
+ enqueue(input: CompletedTurnHookInput): void;
234
+ recordFailure(input: CompletedTurnHookFailure): void;
235
+ };
236
+
214
237
  type CommonRuntimeSessionInput = {
215
238
  workspaceRoot: string;
216
239
  modelName: string;
@@ -231,6 +254,9 @@ type CommonRuntimeSessionInput = {
231
254
  observationLogPath?: string;
232
255
  };
233
256
  webFetchRefiner?: Refiner;
257
+ toolingConfig?: PublicToolingConfig;
258
+ memorySearch?: ToolExecutor;
259
+ completedTurnHook?: CompletedTurnHook;
234
260
  };
235
261
 
236
262
  type CreateNewRuntimeSessionInput = CommonRuntimeSessionInput & {
@@ -531,6 +557,10 @@ class DefaultRuntimeSession implements RuntimeSession {
531
557
  runtimeSession: session.context,
532
558
  historyReader: store.historyReader(),
533
559
  webFetchRefiner: input.webFetchRefiner,
560
+ toolingConfig: input.toolingConfig,
561
+ ...(input.memorySearch === undefined
562
+ ? {}
563
+ : { memorySearch: input.memorySearch }),
534
564
  ...(session.skillCatalog.skills.size === 0
535
565
  ? {}
536
566
  : {
@@ -544,6 +574,8 @@ class DefaultRuntimeSession implements RuntimeSession {
544
574
  session.mcpManager = await dependencies.createMcpManager({
545
575
  config: mcpConfig,
546
576
  runtimeSession: session.context,
577
+ timeoutMs: input.toolingConfig?.mcpTimeoutMs,
578
+ maxObservationChars: input.toolingConfig?.mcpMaxObservationChars,
547
579
  });
548
580
  for (const executor of session.mcpManager.executors) {
549
581
  session.tooling.registry.register(executor, "MCP");
@@ -870,7 +902,7 @@ class DefaultRuntimeSession implements RuntimeSession {
870
902
  return this.input.modelClient.inputModalities?.includes("image") === true;
871
903
  }
872
904
 
873
- importImage(
905
+ async importImage(
874
906
  sourcePath: string,
875
907
  signal: AbortSignal,
876
908
  prospectiveMessageImageCount: number,
@@ -878,9 +910,6 @@ class DefaultRuntimeSession implements RuntimeSession {
878
910
  if (this.state !== "ready") {
879
911
  throw new Error(`Cannot import an image while RuntimeSession is ${this.state}.`);
880
912
  }
881
- if (!this.supportsImageInput()) {
882
- throw new Error("Current model profile does not support image input.");
883
- }
884
913
  if (
885
914
  !Number.isSafeInteger(prospectiveMessageImageCount) ||
886
915
  prospectiveMessageImageCount < 1 ||
@@ -888,18 +917,35 @@ class DefaultRuntimeSession implements RuntimeSession {
888
917
  ) {
889
918
  throw new Error("Prospective Prompt image count is invalid.");
890
919
  }
891
- const activeImageCount = this.input.modelClient.prepare(
892
- this.requireLedger().buildCommittedModelRequest(
893
- this.requireTooling().registry.definitions(),
894
- ).request,
895
- ).mediaOccurrenceCount;
896
- const aggregateImageCount = activeImageCount + prospectiveMessageImageCount;
897
- if (aggregateImageCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
898
- throw new ModelRequestMediaAggregateError(
899
- `Model request would have ${aggregateImageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
900
- );
920
+ const assertImageAllowed = () => {
921
+ if (this.state !== "ready") {
922
+ throw new Error(
923
+ `Cannot import an image while RuntimeSession is ${this.state}.`,
924
+ );
925
+ }
926
+ if (!this.supportsImageInput()) {
927
+ throw new Error("Current model profile does not support image input.");
928
+ }
929
+ const activeImageCount = this.input.modelClient.prepare(
930
+ this.requireLedger().buildCommittedModelRequest(
931
+ this.requireTooling().registry.definitions(),
932
+ ).request,
933
+ ).mediaOccurrenceCount;
934
+ const aggregateImageCount = activeImageCount + prospectiveMessageImageCount;
935
+ if (aggregateImageCount > IMAGE_INPUT_POLICY.maxImagesPerRequest) {
936
+ throw new ModelRequestMediaAggregateError(
937
+ `Model request would have ${aggregateImageCount} images; maximum is ${IMAGE_INPUT_POLICY.maxImagesPerRequest}.`,
938
+ );
939
+ }
940
+ };
941
+
942
+ if (this.supportsImageInput()) {
943
+ assertImageAllowed();
901
944
  }
902
- return this.assetStore.importWorkspaceFile(sourcePath, { signal });
945
+ return this.assetStore.importWorkspaceFile(sourcePath, {
946
+ signal,
947
+ accept: assertImageAllowed,
948
+ });
903
949
  }
904
950
 
905
951
  async verifyImageAssets(
@@ -1698,6 +1744,9 @@ class DefaultRuntimeSession implements RuntimeSession {
1698
1744
  await this.appendTerminalEvent(turn, result, projectedMessageCount);
1699
1745
  pendingLedgerTurn.finish(result);
1700
1746
  settled = true;
1747
+ if (result.status === "completed") {
1748
+ this.notifyCompletedTurn(turn);
1749
+ }
1701
1750
  await this.settleClosedTurnSkills();
1702
1751
  if (result.status === "completed") {
1703
1752
  await this.performAutomaticContextMaintenance();
@@ -1745,6 +1794,55 @@ class DefaultRuntimeSession implements RuntimeSession {
1745
1794
  }
1746
1795
  }
1747
1796
 
1797
+ private notifyCompletedTurn(turn: TurnIdentity): void {
1798
+ const hook = this.input.completedTurnHook;
1799
+ if (hook === undefined) {
1800
+ return;
1801
+ }
1802
+ let snapshot: CompletedTurnSnapshot;
1803
+ try {
1804
+ snapshot = this.store.readCompletedTurnSnapshot(turn.turnId);
1805
+ } catch {
1806
+ this.recordCompletedTurnHookFailure(
1807
+ hook,
1808
+ turn.turnId,
1809
+ "completed_turn_snapshot_failed",
1810
+ );
1811
+ return;
1812
+ }
1813
+ try {
1814
+ hook.enqueue({
1815
+ workspaceRoot: this.input.workspaceRoot,
1816
+ sessionId: this.sessionId,
1817
+ turnId: turn.turnId,
1818
+ snapshot,
1819
+ });
1820
+ } catch {
1821
+ this.recordCompletedTurnHookFailure(
1822
+ hook,
1823
+ turn.turnId,
1824
+ "completed_turn_enqueue_failed",
1825
+ );
1826
+ }
1827
+ }
1828
+
1829
+ private recordCompletedTurnHookFailure(
1830
+ hook: CompletedTurnHook,
1831
+ turnId: TurnId,
1832
+ reason: CompletedTurnHookFailure["reason"],
1833
+ ): void {
1834
+ try {
1835
+ hook.recordFailure({
1836
+ workspaceRoot: this.input.workspaceRoot,
1837
+ sessionId: this.sessionId,
1838
+ turnId,
1839
+ reason,
1840
+ });
1841
+ } catch {
1842
+ // Optional completed-turn integrations never fault a committed turn.
1843
+ }
1844
+ }
1845
+
1748
1846
  private async performAutomaticCompaction(
1749
1847
  qualificationId: string,
1750
1848
  ): Promise<ContextCompactionResult | undefined> {
@@ -0,0 +1,291 @@
1
+ import { Command, CommanderError } from "commander";
2
+ import { PUBLIC_CLI_CONTRACT } from "./public-cli-contract";
3
+ import type { PromptSource } from "./prompt-source";
4
+ import { CliUsageError, type CliCommandScope } from "./output";
5
+
6
+ export type CliCommand =
7
+ | { readonly type: "tui"; readonly profileName?: string }
8
+ | {
9
+ readonly type: "run";
10
+ readonly profileName?: string;
11
+ readonly promptSource: PromptSource;
12
+ };
13
+
14
+ export type CommandLineResult =
15
+ | { readonly type: "command"; readonly command: CliCommand }
16
+ | {
17
+ readonly type: "terminal";
18
+ readonly stdout: string;
19
+ readonly stderr: string;
20
+ };
21
+
22
+ const SUCCESSFUL_TERMINAL_CODES = new Set([
23
+ "commander.help",
24
+ "commander.helpDisplayed",
25
+ "commander.version",
26
+ ]);
27
+
28
+ export async function parseCommandLine(
29
+ args: readonly string[],
30
+ packageVersion: string,
31
+ ): Promise<CommandLineResult> {
32
+ const scopeHint = preflightArgv(args);
33
+ let stdout = "";
34
+ let stderr = "";
35
+ let selectedCommand: CliCommand | undefined;
36
+ const contract = PUBLIC_CLI_CONTRACT;
37
+
38
+ const program = new Command()
39
+ .name(contract.name)
40
+ .description(contract.description)
41
+ .helpOption(contract.helpFlags)
42
+ .version(packageVersion, contract.versionFlags)
43
+ .option(contract.tui.profileOption.flags, contract.tui.profileOption.description)
44
+ .helpCommand(contract.helpCommand.command, contract.helpCommand.description)
45
+ .showHelpAfterError('Run "tinker --help" for usage.')
46
+ .showSuggestionAfterError(false)
47
+ .allowExcessArguments(false)
48
+ .enablePositionalOptions()
49
+ .exitOverride()
50
+ .configureOutput({
51
+ writeOut: (value) => {
52
+ stdout += value;
53
+ },
54
+ writeErr: (value) => {
55
+ stderr += value;
56
+ },
57
+ });
58
+
59
+ program.action(() => {
60
+ const { profile } = program.opts<{ profile?: string }>();
61
+ selectedCommand = Object.freeze({
62
+ type: "tui",
63
+ ...(profile === undefined
64
+ ? {}
65
+ : { profileName: validateProfile(profile, "root") }),
66
+ });
67
+ });
68
+
69
+ program
70
+ .command(contract.run.command)
71
+ .description(contract.run.description)
72
+ .option(contract.run.profileOption.flags, contract.run.profileOption.description)
73
+ .option(contract.run.stdinOption.flags, contract.run.stdinOption.description)
74
+ .option(contract.run.fileOption.flags, contract.run.fileOption.description)
75
+ .addHelpText("after", `\n${contract.run.helpAfter}\n`)
76
+ .showHelpAfterError('Run "tinker run --help" for usage.')
77
+ .showSuggestionAfterError(false)
78
+ .allowExcessArguments(false)
79
+ .exitOverride()
80
+ .action(
81
+ (
82
+ prompt: string | undefined,
83
+ options: { profile?: string; stdin?: boolean; file?: string },
84
+ ) => {
85
+ const sources: PromptSource[] = [];
86
+ if (prompt !== undefined) {
87
+ sources.push({ kind: "argument", value: prompt });
88
+ }
89
+ if (options.stdin === true) {
90
+ sources.push({ kind: "stdin" });
91
+ }
92
+ if (options.file !== undefined) {
93
+ if (options.file.length === 0) {
94
+ throw new CliUsageError("--file requires a non-empty path.", "run");
95
+ }
96
+ sources.push({ kind: "file", filePath: options.file });
97
+ }
98
+ if (sources.length === 0) {
99
+ throw new CliUsageError(
100
+ "Exactly one prompt source is required: [prompt], --stdin, or --file <path>.",
101
+ "run",
102
+ );
103
+ }
104
+ if (sources.length > 1) {
105
+ throw new CliUsageError(
106
+ "Prompt sources are mutually exclusive: use [prompt], --stdin, or --file <path>.",
107
+ "run",
108
+ );
109
+ }
110
+ const promptSource = sources[0];
111
+ if (promptSource === undefined) {
112
+ throw new CliUsageError("A prompt source is required.", "run");
113
+ }
114
+ selectedCommand = Object.freeze({
115
+ type: "run",
116
+ ...(options.profile === undefined
117
+ ? {}
118
+ : { profileName: validateProfile(options.profile, "run") }),
119
+ promptSource,
120
+ });
121
+ },
122
+ );
123
+
124
+ try {
125
+ await program.parseAsync([...args], { from: "user" });
126
+ } catch (error) {
127
+ if (error instanceof CliUsageError) {
128
+ throw error;
129
+ }
130
+ if (error instanceof CommanderError) {
131
+ if (SUCCESSFUL_TERMINAL_CODES.has(error.code)) {
132
+ return Object.freeze({ type: "terminal", stdout, stderr });
133
+ }
134
+ throw new CliUsageError(commanderErrorDetail(error), scopeHint);
135
+ }
136
+ throw error;
137
+ }
138
+
139
+ if (selectedCommand === undefined) {
140
+ throw new Error("Commander completed without selecting a command.");
141
+ }
142
+ const topLevelProfile = program.opts<{ profile?: string }>().profile;
143
+ if (selectedCommand.type === "run" && topLevelProfile !== undefined) {
144
+ throw new CliUsageError(
145
+ "The top-level --profile option only applies to the TUI; place --profile after run.",
146
+ "run",
147
+ );
148
+ }
149
+ return Object.freeze({ type: "command", command: selectedCommand });
150
+ }
151
+
152
+ function preflightArgv(args: readonly string[]): CliCommandScope {
153
+ let scope: CliCommandScope = "root";
154
+ let rootBlocked = false;
155
+ let topProfileOccurrences = 0;
156
+ let runProfileOccurrences = 0;
157
+ let stdinOccurrences = 0;
158
+ let fileOccurrences = 0;
159
+
160
+ for (let index = 0; index < args.length; index += 1) {
161
+ const token = args[index];
162
+ if (token === undefined || token === "--") {
163
+ break;
164
+ }
165
+
166
+ if (
167
+ (!rootBlocked && scope === "root" && isRootTerminalOption(token)) ||
168
+ (scope === "run" && isRunTerminalOption(token))
169
+ ) {
170
+ return scope;
171
+ }
172
+
173
+ if (scope === "root" && !rootBlocked) {
174
+ const profile = readOptionOccurrence(
175
+ args,
176
+ index,
177
+ token,
178
+ "--profile",
179
+ "root",
180
+ "-p",
181
+ );
182
+ if (profile !== undefined) {
183
+ topProfileOccurrences += 1;
184
+ assertSingleOccurrence("--profile", topProfileOccurrences, "root");
185
+ validateProfile(profile.value, "root");
186
+ index += profile.consumedNext ? 1 : 0;
187
+ continue;
188
+ }
189
+ if (token.startsWith("-")) {
190
+ rootBlocked = true;
191
+ continue;
192
+ }
193
+ if (token === "run") {
194
+ scope = "run";
195
+ continue;
196
+ }
197
+ if (token === "help") {
198
+ return "root";
199
+ }
200
+ throw new CliUsageError(`unknown command '${token}'`, "root");
201
+ }
202
+
203
+ if (scope !== "run") {
204
+ continue;
205
+ }
206
+ const profile = readOptionOccurrence(args, index, token, "--profile", "run", "-p");
207
+ if (profile !== undefined) {
208
+ runProfileOccurrences += 1;
209
+ assertSingleOccurrence("--profile", runProfileOccurrences, "run");
210
+ validateProfile(profile.value, "run");
211
+ index += profile.consumedNext ? 1 : 0;
212
+ continue;
213
+ }
214
+ const file = readOptionOccurrence(args, index, token, "--file", "run");
215
+ if (file !== undefined) {
216
+ fileOccurrences += 1;
217
+ assertSingleOccurrence("--file", fileOccurrences, "run");
218
+ if (file.value.length === 0) {
219
+ throw new CliUsageError("--file requires a non-empty path.", "run");
220
+ }
221
+ index += file.consumedNext ? 1 : 0;
222
+ continue;
223
+ }
224
+ if (token === "--stdin") {
225
+ stdinOccurrences += 1;
226
+ assertSingleOccurrence("--stdin", stdinOccurrences, "run");
227
+ }
228
+ }
229
+ return scope;
230
+ }
231
+
232
+ function readOptionOccurrence(
233
+ args: readonly string[],
234
+ index: number,
235
+ token: string,
236
+ longName: string,
237
+ scope: CliCommandScope,
238
+ shortName?: string,
239
+ ): { readonly value: string; readonly consumedNext: boolean } | undefined {
240
+ if (token === longName || token === shortName) {
241
+ const next = args[index + 1];
242
+ if (next === undefined || next.startsWith("-")) {
243
+ throw new CliUsageError(`option '${token}' argument missing`, scope);
244
+ }
245
+ return { value: next, consumedNext: true };
246
+ }
247
+ const longPrefix = `${longName}=`;
248
+ if (token.startsWith(longPrefix)) {
249
+ return { value: token.slice(longPrefix.length), consumedNext: false };
250
+ }
251
+ if (
252
+ shortName !== undefined &&
253
+ token.startsWith(shortName) &&
254
+ token !== shortName &&
255
+ !token.startsWith("--")
256
+ ) {
257
+ return { value: token.slice(shortName.length), consumedNext: false };
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ function validateProfile(value: string, scope: CliCommandScope): string {
263
+ if (value.trim() === "") {
264
+ throw new CliUsageError("--profile requires a non-empty value.", scope);
265
+ }
266
+ return value;
267
+ }
268
+
269
+ function assertSingleOccurrence(
270
+ option: string,
271
+ count: number,
272
+ scope: CliCommandScope,
273
+ ): void {
274
+ if (count > 1) {
275
+ throw new CliUsageError(`${option} may only be specified once.`, scope);
276
+ }
277
+ }
278
+
279
+ function isRootTerminalOption(token: string): boolean {
280
+ return (
281
+ token === "--help" || token === "-h" || token === "--version" || token === "-V"
282
+ );
283
+ }
284
+
285
+ function isRunTerminalOption(token: string): boolean {
286
+ return token === "--help" || token === "-h";
287
+ }
288
+
289
+ function commanderErrorDetail(error: CommanderError): string {
290
+ return error.message.replace(/^error:\s*/u, "");
291
+ }