tinker-agent 2.2.0 → 2.3.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,19 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.3.0] - 2026-08-31
9
+
10
+ ### Changed
11
+
12
+ - Store per-workspace runtime state in the global Tinker home instead of inside
13
+ the project. Sessions (`sessions/<id>/`), background Bash logs (`bash/`),
14
+ image assets (`assets/images/`), and prompt history now live under
15
+ `~/.tinker/projects/<workspace-slug-hash>/`, keyed by the workspace's
16
+ canonical absolute path. Projects no longer grow a `.tinker/` directory.
17
+ Existing in-project `.tinker/` state is left in place but no longer read.
18
+ - Add `TINKER_HOME` to relocate the global Tinker home directory (default: the
19
+ OS home directory).
20
+
8
21
  ## [2.2.0] - 2026-08-29
9
22
 
10
23
  ### Added
@@ -264,7 +277,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
264
277
  - First formal npm release under the `tinker-agent` package name with the `tinker`
265
278
  executable.
266
279
 
267
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.2.0...HEAD
280
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.3.0...HEAD
281
+ [2.3.0]: https://github.com/ishowshao/tinker/releases/tag/v2.3.0
268
282
  [2.2.0]: https://github.com/ishowshao/tinker/releases/tag/v2.2.0
269
283
  [2.1.0]: https://github.com/ishowshao/tinker/releases/tag/v2.1.0
270
284
  [2.0.0]: https://github.com/ishowshao/tinker/releases/tag/v2.0.0
package/README.md CHANGED
@@ -458,13 +458,22 @@ one-shot `tinker run` command. See
458
458
  [`docs/project-custom-slash-commands-design.md`](docs/project-custom-slash-commands-design.md)
459
459
  for the full contract.
460
460
 
461
- ### Workspace-Local Runtime Data
462
-
463
- Tinker keeps private runtime state under `<workspace>/.tinker/`: each session has
464
- its own SQLite database, event log, and observation log, while image assets and
465
- Prompt history are stored at the workspace level. These locations are fixed and
466
- have no public path-override environment variables. MCP server configuration is
467
- loaded from `<workspace>/.mcp.json`; project slash commands are loaded from
461
+ ### Global Runtime Data
462
+
463
+ Tinker keeps private runtime state in a global home directory instead of inside
464
+ your projects. Each workspace maps to
465
+ `~/.tinker/projects/<workspace-slug-hash>/` the slug comes from the workspace
466
+ directory name and the hash from its canonical absolute path, so one project
467
+ always resolves to one storage directory even when reached through symlinks.
468
+ That directory holds per-session SQLite databases, event logs, and observation
469
+ logs (`sessions/<id>/`), background Bash task logs (`bash/`), imported image
470
+ assets (`assets/images/`), and prompt history (`prompt-history.jsonl`).
471
+ Workspaces stay clean: no `.tinker/` directory is created inside them.
472
+
473
+ Set `TINKER_HOME` to relocate the global home (default: the OS home directory).
474
+ Global memory lives in `<home>/.tinker/memory/` and the Chrome bridge host in
475
+ `<home>/.tinker/chrome/`. MCP server configuration is loaded from
476
+ `<workspace>/.mcp.json`; project slash commands are loaded from
468
477
  `<workspace>/.tinker.json`.
469
478
 
470
479
  `Read` has a fixed 262144-byte (256 KiB) content limit per call. A successful
@@ -494,8 +503,9 @@ tinker/
494
503
  │ ├── context/ # Context protocol validation, protocol frame construction
495
504
  │ └── ids/ # Runtime ID generation (UUID v7)
496
505
  ├── docs/ # Design notes and planning documents
497
- ├── .tinker/ # Runtime data (sessions, bash tasks, events)
498
506
  └── package.json
507
+
508
+ Runtime data lives in ~/.tinker/ (sessions, bash tasks, assets), not in the repo.
499
509
  ```
500
510
 
501
511
  ## Design Philosophy
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.2.0",
3
+ "version": "2.3.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",
@@ -287,6 +287,7 @@ export type CompletedTurnHook = {
287
287
 
288
288
  type CommonRuntimeSessionInput = {
289
289
  workspaceRoot: string;
290
+ homeRoot?: string;
290
291
  modelName: string;
291
292
  profileName?: string;
292
293
  maxIterations: number;
@@ -341,7 +342,10 @@ export type RuntimeSessionFactoryDependencies = {
341
342
  idFactory: RuntimeIdFactory,
342
343
  ) => Promise<SessionStore>;
343
344
  createLedger: (store: SessionStore, idFactory: RuntimeIdFactory) => SessionLedger;
344
- createEventSink: (input: CreateRuntimeSessionInput) => EventSink;
345
+ createEventSink: (
346
+ input: CreateRuntimeSessionInput,
347
+ sessionDirectory: string,
348
+ ) => EventSink;
345
349
  selectShadowPlanning: NonNullable<RunAgentInput["shadowPlanning"]>["select"];
346
350
  onShadowPlanningResult?: NonNullable<RunAgentInput["shadowPlanning"]>["onResult"];
347
351
  selectContextAutomation: typeof selectContextAutomation;
@@ -407,10 +411,12 @@ const defaultDependencies: RuntimeSessionFactoryDependencies = {
407
411
  systemPrompt: input.systemPrompt,
408
412
  projectInstruction: input.projectInstruction,
409
413
  idFactory,
414
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
410
415
  })
411
416
  : SessionStore.openExisting({
412
417
  workspaceRoot: input.workspaceRoot,
413
418
  sessionId: input.selection.sessionId,
419
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
414
420
  }),
415
421
  createLedger: (store, idFactory) => new SqliteSessionLedger(store, idFactory),
416
422
  createEventSink,
@@ -541,7 +547,10 @@ class DefaultRuntimeSession implements RuntimeSession {
541
547
  const store = await dependencies.openStore(input, dependencies.idFactory);
542
548
  let assetStore: ImageAssetStore;
543
549
  try {
544
- assetStore = await ImageAssetStore.open({ workspaceRoot: store.workspaceRoot });
550
+ assetStore = await ImageAssetStore.open({
551
+ workspaceRoot: store.workspaceRoot,
552
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
553
+ });
545
554
  } catch (error) {
546
555
  if (isNewSessionInput(input)) {
547
556
  await store
@@ -557,7 +566,7 @@ class DefaultRuntimeSession implements RuntimeSession {
557
566
  session = new DefaultRuntimeSession(
558
567
  input,
559
568
  dependencies,
560
- dependencies.createEventSink(input),
569
+ dependencies.createEventSink(input, store.sessionDirectory),
561
570
  dependencies.createObservationBuilder(),
562
571
  store,
563
572
  assetStore,
@@ -654,6 +663,7 @@ class DefaultRuntimeSession implements RuntimeSession {
654
663
 
655
664
  session.tooling = dependencies.createTooling({
656
665
  workspaceRoot: input.workspaceRoot,
666
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
657
667
  runtimeSession: session.context,
658
668
  historyReader: store.historyReader(),
659
669
  imageAssetStore: assetStore,
@@ -2928,21 +2938,19 @@ export async function createRuntimeSession(
2928
2938
  });
2929
2939
  }
2930
2940
 
2931
- function createEventSink(input: CreateRuntimeSessionInput): EventSink {
2941
+ function createEventSink(
2942
+ input: CreateRuntimeSessionInput,
2943
+ sessionDirectory: string,
2944
+ ): EventSink {
2932
2945
  const requiredSinks: EventSink[] = [];
2933
2946
  if (input.persistence !== false) {
2934
- const basePath = path.join(
2935
- input.workspaceRoot,
2936
- ".tinker",
2937
- "sessions",
2938
- input.selection.sessionId,
2939
- );
2940
2947
  requiredSinks.push(
2941
2948
  new JsonlEventLog(
2942
- input.persistence?.eventLogPath ?? path.join(basePath, "events.jsonl"),
2949
+ input.persistence?.eventLogPath ?? path.join(sessionDirectory, "events.jsonl"),
2943
2950
  ),
2944
2951
  new ObservationTextLog(
2945
- input.persistence?.observationLogPath ?? path.join(basePath, "observations.md"),
2952
+ input.persistence?.observationLogPath ??
2953
+ path.join(sessionDirectory, "observations.md"),
2946
2954
  ),
2947
2955
  );
2948
2956
  }
package/src/cli/config.ts CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  type ParsedPublicEnvironment,
25
25
  type PublicToolingConfig,
26
26
  } from "./public-config-contract";
27
+ import { resolveWorkspaceStorageRoot } from "../session/workspace-storage";
27
28
 
28
29
  export type RunnerConfig = {
29
30
  readonly sessionId: SessionId;
@@ -227,17 +228,12 @@ function runnerConfigFromTemplate(
227
228
  });
228
229
  }
229
230
 
230
- export function eventLogPath(workspaceRoot: string, sessionId: SessionId): string {
231
- return path.join(workspaceRoot, ".tinker", "sessions", sessionId, "events.jsonl");
232
- }
233
-
234
- export function observationLogPath(
231
+ export async function promptHistoryPath(
235
232
  workspaceRoot: string,
236
- sessionId: SessionId,
237
- ): string {
238
- return path.join(workspaceRoot, ".tinker", "sessions", sessionId, "observations.md");
239
- }
240
-
241
- export function promptHistoryPath(workspaceRoot: string): string {
242
- return path.join(workspaceRoot, ".tinker", "prompt-history.jsonl");
233
+ homeRoot?: string,
234
+ ): Promise<string> {
235
+ return path.join(
236
+ await resolveWorkspaceStorageRoot(workspaceRoot, homeRoot),
237
+ "prompt-history.jsonl",
238
+ );
243
239
  }
@@ -160,7 +160,9 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
160
160
  config.sessionId,
161
161
  projectionStore,
162
162
  );
163
- const promptHistory = await PromptHistory.load(promptHistoryPath(workspaceRoot));
163
+ const promptHistory = await PromptHistory.load(
164
+ await promptHistoryPath(workspaceRoot),
165
+ );
164
166
  const catalog = new SessionCatalog({ workspaceRoot });
165
167
 
166
168
  const openStoredSession = async (
@@ -21,6 +21,7 @@ import {
21
21
  type ImageAssetId,
22
22
  type ImageAssetRef,
23
23
  } from "./image-types";
24
+ import { canonicalHomeRoot, workspaceStorageRoot } from "../session/workspace-storage";
24
25
 
25
26
  const STAGING_MAX_AGE_MS = 24 * 60 * 60 * 1_000;
26
27
  const STAGING_PATTERN =
@@ -47,13 +48,18 @@ export class ImageAssetStore {
47
48
  static async open(input: {
48
49
  workspaceRoot: string;
49
50
  onWarning?: (message: string) => void;
51
+ homeRoot?: string;
50
52
  }): Promise<ImageAssetStore> {
51
53
  const workspaceRoot = await realpath(input.workspaceRoot);
52
54
  const workspaceStat = await stat(workspaceRoot);
53
55
  if (!workspaceStat.isDirectory()) {
54
56
  throw new Error(`Workspace root is not a directory: ${workspaceRoot}.`);
55
57
  }
56
- const root = await ensureAssetRoot(workspaceRoot);
58
+ const storageRoot = workspaceStorageRoot(
59
+ workspaceRoot,
60
+ await canonicalHomeRoot(input.homeRoot),
61
+ );
62
+ const root = await ensureAssetRoot(storageRoot);
57
63
  const store = new ImageAssetStore(workspaceRoot, root, input.onWarning);
58
64
  await store.cleanupStagingFiles();
59
65
  return store;
@@ -271,9 +277,18 @@ export class ImageAssetStore {
271
277
  }
272
278
  }
273
279
 
274
- async function ensureAssetRoot(workspaceRoot: string): Promise<string> {
275
- let current = workspaceRoot;
276
- for (const name of [".tinker", "assets", "images"]) {
280
+ async function ensureAssetRoot(storageRoot: string): Promise<string> {
281
+ await mkdir(storageRoot, { recursive: true, mode: 0o700 });
282
+ const storageEntry = await lstat(storageRoot);
283
+ if (storageEntry.isSymbolicLink() || !storageEntry.isDirectory()) {
284
+ throw new Error(
285
+ `Workspace storage root is not a regular directory: ${storageRoot}.`,
286
+ );
287
+ }
288
+ await chmod(storageRoot, 0o700);
289
+
290
+ let current = storageRoot;
291
+ for (const name of ["assets", "images"]) {
277
292
  current = path.join(current, name);
278
293
  try {
279
294
  const entry = await lstat(current);
@@ -288,12 +303,10 @@ async function ensureAssetRoot(workspaceRoot: string): Promise<string> {
288
303
  }
289
304
  await mkdir(current, { mode: 0o700 });
290
305
  }
291
- if (name !== ".tinker") {
292
- await chmod(current, 0o700);
293
- }
306
+ await chmod(current, 0o700);
294
307
  }
295
308
  const canonical = await realpath(current);
296
- assertContained(workspaceRoot, canonical, "Image asset root");
309
+ assertContained(storageRoot, canonical, "Image asset root");
297
310
  if (canonical !== current) {
298
311
  throw new Error("Image asset root is not canonical.");
299
312
  }
@@ -131,6 +131,12 @@ export type MemoryExtractionDiagnostic = {
131
131
  readonly written: number;
132
132
  readonly rejected: MemoryExtractionRejectedCounts;
133
133
  readonly ms: number;
134
+ /**
135
+ * Bounded single-line error detail (message plus cause chain) recorded for
136
+ * failed and skipped outcomes so provider/parse failures are diagnosable
137
+ * from the log alone. Absent on success.
138
+ */
139
+ readonly detail?: string;
134
140
  };
135
141
 
136
142
  export type MemorySearchDiagnostic = {
@@ -196,6 +202,31 @@ export function boundedMemoryError(error: unknown): string {
196
202
  return truncateUtf8(singleLine, 400);
197
203
  }
198
204
 
205
+ export function boundedMemoryErrorDetail(error: unknown): string {
206
+ const parts: string[] = [];
207
+ let current: unknown = error;
208
+ for (
209
+ let depth = 0;
210
+ depth < 4 && current !== undefined && current !== null;
211
+ depth += 1
212
+ ) {
213
+ const raw =
214
+ current instanceof Error
215
+ ? current.message
216
+ : typeof current === "string" ||
217
+ typeof current === "number" ||
218
+ typeof current === "boolean"
219
+ ? String(current)
220
+ : "unknown non-error cause";
221
+ const singleLine = raw.replaceAll(/\s+/g, " ").trim();
222
+ if (singleLine !== "" && !parts.includes(singleLine)) {
223
+ parts.push(singleLine);
224
+ }
225
+ current = current instanceof Error ? current.cause : undefined;
226
+ }
227
+ return truncateUtf8(parts.join(" | ") || "unknown memory error", 400);
228
+ }
229
+
199
230
  export function truncateUtf8(value: string, maxBytes: number): string {
200
231
  if (Buffer.byteLength(value, "utf8") <= maxBytes) {
201
232
  return value;
@@ -25,6 +25,7 @@ import {
25
25
  MEMORY_SEARCH_LIMIT,
26
26
  MEMORY_SEARCH_TOOL_NAME,
27
27
  MemoryError,
28
+ boundedMemoryErrorDetail,
28
29
  truncateUtf8,
29
30
  type MemoryEmbeddingConfig,
30
31
  type MemoryExtractionDiagnostic,
@@ -248,6 +249,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
248
249
  turnId: task.turnId,
249
250
  inputTokens,
250
251
  ms: elapsedMs(startedAt),
252
+ detail: boundedMemoryErrorDetail(error),
251
253
  }),
252
254
  );
253
255
  return;
@@ -272,6 +274,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
272
274
  returned,
273
275
  rejected,
274
276
  ms: elapsedMs(startedAt),
277
+ detail: boundedMemoryErrorDetail(error),
275
278
  }),
276
279
  );
277
280
  return;
@@ -343,6 +346,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
343
346
  returned,
344
347
  rejected,
345
348
  ms: elapsedMs(startedAt),
349
+ detail: boundedMemoryErrorDetail(error),
346
350
  }),
347
351
  );
348
352
  return;
@@ -401,6 +405,7 @@ export class MemoryCoordinator implements CompletedTurnHook {
401
405
  returned,
402
406
  rejected,
403
407
  ms: elapsedMs(startedAt),
408
+ detail: boundedMemoryErrorDetail(error),
404
409
  }),
405
410
  );
406
411
  }
@@ -663,6 +668,7 @@ function extractionDiagnostic(input: {
663
668
  readonly returned?: number;
664
669
  readonly written?: number;
665
670
  readonly rejected?: MemoryExtractionRejectedCounts;
671
+ readonly detail?: string;
666
672
  }): MemoryExtractionDiagnostic {
667
673
  return Object.freeze({
668
674
  at: input.clock(),
@@ -676,6 +682,7 @@ function extractionDiagnostic(input: {
676
682
  written: input.written ?? 0,
677
683
  rejected: input.rejected ?? emptyRejectedCounts(),
678
684
  ms: input.ms,
685
+ ...(input.detail === undefined ? {} : { detail: input.detail }),
679
686
  });
680
687
  }
681
688
 
@@ -13,6 +13,7 @@ import {
13
13
  MAX_MEMORY_SUMMARY_BYTES,
14
14
  MAX_MEMORY_TEXT_BYTES,
15
15
  MemoryError,
16
+ truncateUtf8,
16
17
  } from "./contracts";
17
18
 
18
19
  const EXTRACTION_SYSTEM_PROMPT = `You record one faithful historical summary of a completed coding-agent turn. Your job is to record what happened, not to judge what deserves long-term storage.
@@ -35,6 +36,10 @@ Rules:
35
36
  - Do not copy long passages. Keep both fields dense and within their byte budgets.
36
37
  `;
37
38
 
39
+ const MEMORY_EXTRACTION_RESPONSE_FORMAT = Object.freeze({
40
+ type: "json_object" as const,
41
+ });
42
+
38
43
  export type MemoryExtractionCandidate = {
39
44
  readonly text: string;
40
45
  readonly summary: string;
@@ -102,7 +107,14 @@ export class MemoryExtractor {
102
107
  let prepared: PreparedModelRequest;
103
108
  let inputTokens: number;
104
109
  try {
105
- prepared = this.model.prepare({ messages, tools: [] });
110
+ prepared = this.model.prepare({
111
+ messages,
112
+ tools: [],
113
+ // Provider-enforced JSON mode: prompt wording alone lets the model
114
+ // wrap the record in markdown fences or prose, which previously
115
+ // surfaced as extraction_output_invalid failures and dropped memories.
116
+ responseFormat: MEMORY_EXTRACTION_RESPONSE_FORMAT,
117
+ });
106
118
  const rawInputTokens = estimatePromptSegments(
107
119
  prepared.promptSegments,
108
120
  ).totalTokens;
@@ -160,6 +172,15 @@ export class MemoryExtractor {
160
172
  }
161
173
  }
162
174
 
175
+ function outputPreview(content: string | null | undefined): string {
176
+ if (typeof content !== "string" || content.trim() === "") {
177
+ return "(empty)";
178
+ }
179
+ const singleLine = content.replaceAll(/\s+/g, " ").trim();
180
+ const preview = truncateUtf8(singleLine, 160);
181
+ return preview === singleLine ? preview : `${preview}…`;
182
+ }
183
+
163
184
  function parseExtractionOutput(message: {
164
185
  readonly content?: string | null;
165
186
  readonly toolCalls?: readonly unknown[];
@@ -169,7 +190,7 @@ function parseExtractionOutput(message: {
169
190
  (message.toolCalls !== undefined && message.toolCalls.length > 0)
170
191
  ) {
171
192
  throw new MemoryExtractionOutputError(
172
- "Memory extraction response must contain only JSON text.",
193
+ `Memory extraction response must contain only JSON text. output=${outputPreview(message.content)}`,
173
194
  0,
174
195
  );
175
196
  }
@@ -179,7 +200,7 @@ function parseExtractionOutput(message: {
179
200
  value = JSON.parse(message.content);
180
201
  } catch (error) {
181
202
  throw new MemoryExtractionOutputError(
182
- "Memory extraction response is not valid JSON.",
203
+ `Memory extraction response is not valid JSON. output=${outputPreview(message.content)}`,
183
204
  0,
184
205
  0,
185
206
  { cause: error },
@@ -187,7 +208,7 @@ function parseExtractionOutput(message: {
187
208
  }
188
209
  if (!isRecord(value)) {
189
210
  throw new MemoryExtractionOutputError(
190
- "Memory extraction response must be an object.",
211
+ `Memory extraction response must be an object. output=${outputPreview(message.content)}`,
191
212
  0,
192
213
  );
193
214
  }
@@ -200,7 +221,7 @@ function parseExtractionOutput(message: {
200
221
  typeof value.summary !== "string"
201
222
  ) {
202
223
  throw new MemoryExtractionOutputError(
203
- 'Memory extraction response must contain only "text" and "summary" strings.',
224
+ `Memory extraction response must contain only "text" and "summary" strings. output=${outputPreview(message.content)}`,
204
225
  0,
205
226
  );
206
227
  }
@@ -86,6 +86,7 @@ export class FakeModelClient implements ModelClient {
86
86
  requestMaxOutputTokens: this.options.contextBudget.requestMaxOutputTokens,
87
87
  inputModalities: this.inputModalities,
88
88
  toolResultModalities: this.toolResultModalities,
89
+ responseFormat: input.responseFormat?.type ?? null,
89
90
  }),
90
91
  );
91
92
  const prepared: PreparedModelRequest = Object.freeze({
@@ -96,6 +97,9 @@ export class FakeModelClient implements ModelClient {
96
97
  tools: Object.freeze([...input.tools]),
97
98
  maxTokens: this.options.contextBudget.requestMaxOutputTokens,
98
99
  ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
100
+ ...(input.responseFormat === undefined
101
+ ? {}
102
+ : { responseFormat: input.responseFormat }),
99
103
  }),
100
104
  promptSegments: Object.freeze([...toolSegments, ...messageSegments]),
101
105
  requestConfigHash,
@@ -111,6 +115,9 @@ export class FakeModelClient implements ModelClient {
111
115
  this.preparedInputs.set(prepared, {
112
116
  messages: [...input.messages],
113
117
  tools: [...input.tools],
118
+ ...(input.responseFormat === undefined
119
+ ? {}
120
+ : { responseFormat: input.responseFormat }),
114
121
  });
115
122
  return prepared;
116
123
  }
@@ -103,9 +103,20 @@ export type ModelRequestOptions = {
103
103
  };
104
104
  };
105
105
 
106
+ /**
107
+ * Provider-enforced structured output for a single request. Distinct from
108
+ * prompt-level instructions: the provider constrains the decoded response to
109
+ * be a valid JSON object, so callers still validate shape but no longer fight
110
+ * markdown fences or surrounding prose.
111
+ */
112
+ export type ModelResponseFormat = {
113
+ readonly type: "json_object";
114
+ };
115
+
106
116
  export type ModelRequestInput = {
107
117
  messages: AgentMessage[];
108
118
  tools: ToolDefinition[];
119
+ responseFormat?: ModelResponseFormat;
109
120
  };
110
121
 
111
122
  export type PreparedPromptSegmentKind =
@@ -107,6 +107,9 @@ export class OpenAIChatModelClient implements ModelClient {
107
107
  messages,
108
108
  ...(tools === undefined ? {} : { tools, tool_choice: "auto" as const }),
109
109
  ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }),
110
+ ...(input.responseFormat === undefined
111
+ ? {}
112
+ : { response_format: { type: input.responseFormat.type } }),
110
113
  max_completion_tokens: this.options.contextBudget.requestMaxOutputTokens,
111
114
  ...(this.stream
112
115
  ? {
@@ -147,7 +150,10 @@ export class OpenAIChatModelClient implements ModelClient {
147
150
  stream: this.stream,
148
151
  inputModalities: this.inputModalities,
149
152
  toolResultModalities: this.toolResultModalities,
150
- requestPolicy: { toolChoice: "auto" },
153
+ requestPolicy: {
154
+ toolChoice: "auto",
155
+ responseFormat: input.responseFormat?.type ?? null,
156
+ },
151
157
  imagePolicy: {
152
158
  version: IMAGE_INPUT_POLICY_VERSION,
153
159
  ...IMAGE_INPUT_POLICY,
@@ -111,6 +111,9 @@ export class OpenAIResponsesModelClient implements ModelClient {
111
111
  : { reasoning: { effort: reasoningEffort } }),
112
112
  max_output_tokens: this.options.contextBudget.requestMaxOutputTokens,
113
113
  store: false as const,
114
+ ...(input.responseFormat === undefined
115
+ ? {}
116
+ : { text: { format: { type: input.responseFormat.type } } }),
114
117
  ...(this.stream ? { stream: true as const } : {}),
115
118
  });
116
119
  const toolSegments = (tools ?? []).map(
@@ -148,7 +151,11 @@ export class OpenAIResponsesModelClient implements ModelClient {
148
151
  stream: this.stream,
149
152
  inputModalities: this.inputModalities,
150
153
  toolResultModalities: this.toolResultModalities,
151
- requestPolicy: { store: false, toolChoice: "auto" },
154
+ requestPolicy: {
155
+ store: false,
156
+ toolChoice: "auto",
157
+ responseFormat: input.responseFormat?.type ?? null,
158
+ },
152
159
  imagePolicy: {
153
160
  version: IMAGE_INPUT_POLICY_VERSION,
154
161
  ...IMAGE_INPUT_POLICY,
@@ -1,4 +1,3 @@
1
- import path from "node:path";
2
1
  import { realpath } from "node:fs/promises";
3
2
  import { Database } from "bun:sqlite";
4
3
  import type { ToolCall } from "../agent/types";
@@ -24,7 +23,11 @@ import {
24
23
  } from "../tui/tui-projection-policy";
25
24
  import { SessionError } from "./session-errors";
26
25
  import { verifySessionSchema } from "./session-schema";
27
- import { decodeStoredToolCalls, decodeStoredToolRawResult } from "./session-store";
26
+ import {
27
+ decodeStoredToolCalls,
28
+ decodeStoredToolRawResult,
29
+ resolveSessionDatabasePath,
30
+ } from "./session-store";
28
31
  import {
29
32
  MAX_TIMELINE_PROMPT_CODE_POINTS,
30
33
  projectUserMessage,
@@ -46,17 +49,16 @@ export class ResumeProjectionReader {
46
49
  sessionId: SessionId;
47
50
  modelName: string;
48
51
  policy?: TuiProjectionPolicy;
52
+ homeRoot?: string;
49
53
  }): Promise<TuiProjectionState> {
50
54
  const policy = validateTuiProjectionPolicy(
51
55
  input.policy ?? defaultTuiProjectionPolicy,
52
56
  );
53
57
  const workspaceRoot = await realpath(input.workspaceRoot);
54
- const databasePath = path.join(
58
+ const databasePath = await resolveSessionDatabasePath(
55
59
  workspaceRoot,
56
- ".tinker",
57
- "sessions",
58
60
  input.sessionId,
59
- "session.sqlite",
61
+ input.homeRoot,
60
62
  );
61
63
  const database = new Database(databasePath, {
62
64
  readonly: true,
@@ -6,6 +6,7 @@ import { SessionError } from "./session-errors";
6
6
  import { inspectSessionLock } from "./session-lock";
7
7
  import { SessionStore } from "./session-store";
8
8
  import { verifyReadableSessionSchema } from "./session-schema";
9
+ import { canonicalHomeRoot, workspaceStorageRoot } from "./workspace-storage";
9
10
 
10
11
  export type SessionSummary = {
11
12
  sessionId: SessionId;
@@ -28,9 +29,22 @@ export type SessionSummary = {
28
29
 
29
30
  export class SessionCatalog {
30
31
  private readonly workspaceRootPromise: Promise<string>;
32
+ private readonly sessionsRootPromise: Promise<string>;
31
33
 
32
- constructor(private readonly input: { workspaceRoot: string; limit?: number }) {
34
+ constructor(
35
+ private readonly input: {
36
+ workspaceRoot: string;
37
+ limit?: number;
38
+ homeRoot?: string;
39
+ },
40
+ ) {
33
41
  this.workspaceRootPromise = realpath(input.workspaceRoot);
42
+ this.sessionsRootPromise = this.workspaceRootPromise.then(async (workspaceRoot) =>
43
+ path.join(
44
+ workspaceStorageRoot(workspaceRoot, await canonicalHomeRoot(input.homeRoot)),
45
+ "sessions",
46
+ ),
47
+ );
34
48
  }
35
49
 
36
50
  async list(currentSessionId?: SessionId): Promise<readonly SessionSummary[]> {
@@ -44,7 +58,7 @@ export class SessionCatalog {
44
58
 
45
59
  private async scan(currentSessionId?: SessionId): Promise<SessionSummary[]> {
46
60
  const workspaceRoot = await this.workspaceRootPromise;
47
- const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
61
+ const sessionsRoot = await this.sessionsRootPromise;
48
62
  let entries;
49
63
  try {
50
64
  entries = await readdir(sessionsRoot, { withFileTypes: true });
@@ -87,7 +101,7 @@ export class SessionCatalog {
87
101
  currentSessionId?: SessionId,
88
102
  ): Promise<SessionSummary> {
89
103
  const workspaceRoot = await this.workspaceRootPromise;
90
- const directory = path.join(workspaceRoot, ".tinker", "sessions", sessionId);
104
+ const directory = path.join(await this.sessionsRootPromise, sessionId);
91
105
  return readSummary(directory, sessionId, workspaceRoot, currentSessionId);
92
106
  }
93
107
 
@@ -105,6 +119,7 @@ export class SessionCatalog {
105
119
  workspaceRoot,
106
120
  sessionId,
107
121
  allowIncomplete: true,
122
+ ...(this.input.homeRoot === undefined ? {} : { homeRoot: this.input.homeRoot }),
108
123
  });
109
124
  try {
110
125
  await store.deleteFromDisk();
@@ -4,22 +4,26 @@ import { contentHash } from "../context/protocol-frame";
4
4
  import type { SessionId } from "../ids/runtime-id";
5
5
  import { SessionError, sessionOpenError, sessionReadError } from "./session-errors";
6
6
  import { verifySessionSchema } from "./session-schema";
7
- import { decodeStoredToolCalls, sessionDatabasePath } from "./session-store";
7
+ import { decodeStoredToolCalls, resolveSessionDatabasePath } from "./session-store";
8
8
 
9
9
  const OPERATION = "read_last_assistant_response";
10
10
 
11
11
  export async function readLastAssistantResponse(input: {
12
12
  workspaceRoot: string;
13
13
  sessionId: SessionId;
14
+ homeRoot?: string;
14
15
  }): Promise<string | undefined> {
15
16
  const workspaceRoot = await realpath(input.workspaceRoot);
16
17
  let database: Database;
17
18
  try {
18
- database = new Database(sessionDatabasePath(workspaceRoot, input.sessionId), {
19
- readonly: true,
20
- strict: true,
21
- safeIntegers: true,
22
- });
19
+ database = new Database(
20
+ await resolveSessionDatabasePath(workspaceRoot, input.sessionId, input.homeRoot),
21
+ {
22
+ readonly: true,
23
+ strict: true,
24
+ safeIntegers: true,
25
+ },
26
+ );
23
27
  } catch (error) {
24
28
  throw sessionOpenError(OPERATION, input.sessionId, error);
25
29
  }
@@ -34,6 +34,11 @@ import {
34
34
  } from "../model/model-client";
35
35
  import type { ToolDefinition, ToolRawResult } from "../tools/types";
36
36
  import { sha256, stableJsonStringify } from "../model/model-request-preflight";
37
+ import {
38
+ canonicalHomeRoot,
39
+ resolveWorkspaceStorageRoot,
40
+ workspaceStorageRoot,
41
+ } from "./workspace-storage";
37
42
  import {
38
43
  ContextProtocolError,
39
44
  ContextProtocolValidator,
@@ -418,6 +423,7 @@ export type CreateNewSessionStoreInput = {
418
423
  projectInstruction?: ProjectInstructionManifest;
419
424
  idFactory: RuntimeIdFactory;
420
425
  clock?: () => string;
426
+ homeRoot?: string;
421
427
  };
422
428
 
423
429
  export type OpenSessionStoreInput = {
@@ -425,6 +431,7 @@ export type OpenSessionStoreInput = {
425
431
  sessionId: SessionId;
426
432
  clock?: () => string;
427
433
  allowIncomplete?: boolean;
434
+ homeRoot?: string;
428
435
  };
429
436
 
430
437
  export class SessionStore implements SessionLedgerCommitter {
@@ -447,6 +454,7 @@ export class SessionStore implements SessionLedgerCommitter {
447
454
  sessionDirectory: string;
448
455
  databasePath: string;
449
456
  clock: () => string;
457
+ homeRoot?: string;
450
458
  },
451
459
  ) {
452
460
  this.sessionId = input.sessionId;
@@ -454,14 +462,17 @@ export class SessionStore implements SessionLedgerCommitter {
454
462
  this.sessionDirectory = input.sessionDirectory;
455
463
  this.databasePath = input.databasePath;
456
464
  this.clock = input.clock;
465
+ this.homeRoot = input.homeRoot;
457
466
  }
458
467
 
468
+ private readonly homeRoot?: string;
469
+
459
470
  private readonly clock: () => string;
460
471
 
461
472
  static async createNew(input: CreateNewSessionStoreInput): Promise<SessionStore> {
462
473
  const clock = input.clock ?? (() => new Date().toISOString());
463
474
  const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
464
- const sessionsRoot = await ensureSessionsRoot(workspaceRoot);
475
+ const sessionsRoot = await ensureSessionsRoot(workspaceRoot, input.homeRoot);
465
476
  const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
466
477
  try {
467
478
  await mkdir(sessionDirectory, { mode: 0o700 });
@@ -540,6 +551,7 @@ export class SessionStore implements SessionLedgerCommitter {
540
551
  sessionDirectory,
541
552
  databasePath,
542
553
  clock,
554
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
543
555
  });
544
556
  await store.correctDatabaseModes();
545
557
  store.validateCreatingState();
@@ -558,7 +570,10 @@ export class SessionStore implements SessionLedgerCommitter {
558
570
  static async openExisting(input: OpenSessionStoreInput): Promise<SessionStore> {
559
571
  const clock = input.clock ?? (() => new Date().toISOString());
560
572
  const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
561
- const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
573
+ const sessionsRoot = path.join(
574
+ workspaceStorageRoot(workspaceRoot, await canonicalHomeRoot(input.homeRoot)),
575
+ "sessions",
576
+ );
562
577
  await validateSessionsRoot(sessionsRoot, input.sessionId);
563
578
  const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
564
579
  await validateSecureDirectory(sessionDirectory, input.sessionId);
@@ -593,6 +608,7 @@ export class SessionStore implements SessionLedgerCommitter {
593
608
  sessionDirectory,
594
609
  databasePath,
595
610
  clock,
611
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
596
612
  });
597
613
  store.recallIndexRebuilt = recallIndexContractUpgraded;
598
614
  const meta = store.readMeta();
@@ -2366,7 +2382,10 @@ export class SessionStore implements SessionLedgerCommitter {
2366
2382
  if (distinct.size === 0) {
2367
2383
  return;
2368
2384
  }
2369
- const store = await ImageAssetStore.open({ workspaceRoot: this.workspaceRoot });
2385
+ const store = await ImageAssetStore.open({
2386
+ workspaceRoot: this.workspaceRoot,
2387
+ ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
2388
+ });
2370
2389
  for (const asset of distinct.values()) {
2371
2390
  await store.verify(asset);
2372
2391
  }
@@ -2670,6 +2689,7 @@ export class SessionStore implements SessionLedgerCommitter {
2670
2689
  sessionDirectory: stagingDirectory,
2671
2690
  databasePath: stagingDatabasePath,
2672
2691
  clock: this.clock,
2692
+ ...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
2673
2693
  });
2674
2694
  clonedStore.validateAll({ allowOpenTail: false });
2675
2695
  await clonedStore.verifyImageAssetFiles();
@@ -3802,11 +3822,17 @@ function normalizeInputModalities(
3802
3822
  );
3803
3823
  }
3804
3824
 
3805
- export function sessionDatabasePath(
3825
+ export async function resolveSessionDatabasePath(
3806
3826
  workspaceRoot: string,
3807
3827
  sessionId: SessionId,
3808
- ): string {
3809
- return path.join(workspaceRoot, ".tinker", "sessions", sessionId, "session.sqlite");
3828
+ homeRoot?: string,
3829
+ ): Promise<string> {
3830
+ return path.join(
3831
+ await resolveWorkspaceStorageRoot(workspaceRoot, homeRoot),
3832
+ "sessions",
3833
+ sessionId,
3834
+ "session.sqlite",
3835
+ );
3810
3836
  }
3811
3837
 
3812
3838
  function insertFrame(database: Database, frame: ProtocolFrame): void {
@@ -6081,8 +6107,14 @@ async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
6081
6107
  return realpath(workspaceRoot);
6082
6108
  }
6083
6109
 
6084
- async function ensureSessionsRoot(workspaceRoot: string): Promise<string> {
6085
- const tinkerRoot = path.join(workspaceRoot, ".tinker");
6110
+ async function ensureSessionsRoot(
6111
+ workspaceRoot: string,
6112
+ homeRoot?: string,
6113
+ ): Promise<string> {
6114
+ const tinkerRoot = workspaceStorageRoot(
6115
+ workspaceRoot,
6116
+ await canonicalHomeRoot(homeRoot),
6117
+ );
6086
6118
  const sessionsRoot = path.join(tinkerRoot, "sessions");
6087
6119
  await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
6088
6120
  await validateSessionsRoot(sessionsRoot);
@@ -0,0 +1,84 @@
1
+ import { createHash } from "node:crypto";
2
+ import { realpath } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ const TINKER_HOME_DIR = ".tinker";
7
+ const PROJECTS_DIR = "projects";
8
+ const SLUG_MAX_LENGTH = 24;
9
+ const HASH_HEX_LENGTH = 8;
10
+ const FALLBACK_SLUG = "project";
11
+
12
+ export const TINKER_HOME_ENV = "TINKER_HOME";
13
+
14
+ /**
15
+ * Base directory for global Tinker state. TINKER_HOME overrides the OS home
16
+ * directory; tests and the PTY harness use it to isolate state.
17
+ */
18
+ export function defaultHomeRoot(env: NodeJS.ProcessEnv = process.env): string {
19
+ const override = env[TINKER_HOME_ENV]?.trim();
20
+ return override === undefined || override === "" ? os.homedir() : override;
21
+ }
22
+
23
+ /**
24
+ * Stable directory name for one workspace inside the global Tinker home, e.g.
25
+ * "tinker-a1b2c3d4". The slug is decorative; the hash suffix over the
26
+ * canonical workspace path provides uniqueness.
27
+ */
28
+ export function workspaceStorageDirectoryName(canonicalWorkspaceRoot: string): string {
29
+ const slug = slugify(path.basename(canonicalWorkspaceRoot));
30
+ const hash = createHash("sha256")
31
+ .update(canonicalWorkspaceRoot)
32
+ .digest("hex")
33
+ .slice(0, HASH_HEX_LENGTH);
34
+ return `${slug}-${hash}`;
35
+ }
36
+
37
+ /**
38
+ * Storage root for one canonical workspace root under a canonical home root:
39
+ * <home>/.tinker/projects/<slug-hash>. Both inputs must already be canonical
40
+ * (see resolveWorkspaceStorageRoot for the resolving variant).
41
+ */
42
+ export function workspaceStorageRoot(
43
+ canonicalWorkspaceRoot: string,
44
+ canonicalHomeRoot: string,
45
+ ): string {
46
+ return path.join(
47
+ canonicalHomeRoot,
48
+ TINKER_HOME_DIR,
49
+ PROJECTS_DIR,
50
+ workspaceStorageDirectoryName(canonicalWorkspaceRoot),
51
+ );
52
+ }
53
+
54
+ /** Canonical (symlink-resolved) home root used for global Tinker state. */
55
+ export async function canonicalHomeRoot(
56
+ homeRoot: string = defaultHomeRoot(),
57
+ ): Promise<string> {
58
+ return realpath(homeRoot);
59
+ }
60
+
61
+ /**
62
+ * Resolves the per-workspace storage root under the global Tinker home. Both
63
+ * the workspace and the home root are canonicalized so a project reached
64
+ * through different symlinked paths maps to a single storage directory.
65
+ */
66
+ export async function resolveWorkspaceStorageRoot(
67
+ workspaceRoot: string,
68
+ homeRoot: string = defaultHomeRoot(),
69
+ ): Promise<string> {
70
+ return workspaceStorageRoot(
71
+ await realpath(workspaceRoot),
72
+ await canonicalHomeRoot(homeRoot),
73
+ );
74
+ }
75
+
76
+ function slugify(name: string): string {
77
+ const slug = name
78
+ .toLowerCase()
79
+ .replace(/[^a-z0-9]+/g, "-")
80
+ .replace(/^-+/, "")
81
+ .slice(0, SLUG_MAX_LENGTH)
82
+ .replace(/-+$/, "");
83
+ return slug === "" ? FALLBACK_SLUG : slug;
84
+ }
@@ -20,6 +20,7 @@ import {
20
20
  TERMINAL_SCREEN_ROWS,
21
21
  type TerminalScreen,
22
22
  } from "./terminal-screen";
23
+ import { resolveWorkspaceStorageRoot } from "../session/workspace-storage";
23
24
 
24
25
  export type ShellTaskStatus =
25
26
  | "running"
@@ -112,6 +113,7 @@ export type ShellTaskManagerOptions = {
112
113
  cwdState: CwdState;
113
114
  runtimeSession: RuntimeSessionContext;
114
115
  stopGraceMs?: number;
116
+ homeRoot?: string;
115
117
  };
116
118
 
117
119
  const defaultStopGraceMs = 2_000;
@@ -121,6 +123,7 @@ export class ShellTaskManager {
121
123
  private readonly stopGraceMs: number;
122
124
  private acceptingTasks = true;
123
125
  private shutdownPromise?: Promise<ShutdownResult>;
126
+ private bashDirectoryPromise?: Promise<string>;
124
127
 
125
128
  constructor(private readonly options: ShellTaskManagerOptions) {
126
129
  this.stopGraceMs = options.stopGraceMs ?? defaultStopGraceMs;
@@ -129,6 +132,14 @@ export class ShellTaskManager {
129
132
  }
130
133
  }
131
134
 
135
+ private bashDirectory(): Promise<string> {
136
+ this.bashDirectoryPromise ??= resolveWorkspaceStorageRoot(
137
+ this.options.workspaceRoot,
138
+ this.options.homeRoot,
139
+ ).then((storageRoot) => path.join(storageRoot, "bash"));
140
+ return this.bashDirectoryPromise;
141
+ }
142
+
132
143
  async start(input: {
133
144
  command: string;
134
145
  description: string;
@@ -140,18 +151,9 @@ export class ShellTaskManager {
140
151
  }
141
152
 
142
153
  const id = createUuidV7();
143
- const outputFilePath = path.join(
144
- this.options.workspaceRoot,
145
- ".tinker",
146
- "bash",
147
- `${id}.log`,
148
- );
149
- const cwdFilePath = path.join(
150
- this.options.workspaceRoot,
151
- ".tinker",
152
- "bash",
153
- `${id}.cwd`,
154
- );
154
+ const bashDirectory = await this.bashDirectory();
155
+ const outputFilePath = path.join(bashDirectory, `${id}.log`);
156
+ const cwdFilePath = path.join(bashDirectory, `${id}.cwd`);
155
157
  await ensureEmptyFile(cwdFilePath);
156
158
 
157
159
  const output = await TaskOutput.create(outputFilePath);
@@ -153,6 +153,7 @@ export function createDefaultTooling(options: {
153
153
  workspaceRoot: string;
154
154
  runtimeSession: RuntimeSessionContext;
155
155
  historyReader: SessionHistoryReader;
156
+ homeRoot?: string;
156
157
  maxReadContentBytes?: number;
157
158
  exaApiKey?: string;
158
159
  webFetchRefiner?: Refiner;
@@ -187,6 +188,7 @@ export function createDefaultTooling(options: {
187
188
  cwdState,
188
189
  runtimeSession,
189
190
  stopGraceMs: options.taskStopGraceMs,
191
+ ...(options.homeRoot === undefined ? {} : { homeRoot: options.homeRoot }),
190
192
  });
191
193
 
192
194
  registry.register(