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 +15 -1
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/agent/runtime-session.ts +20 -12
- package/src/cli/config.ts +8 -12
- package/src/cli/tui-runner.tsx +3 -1
- package/src/image/image-asset-store.ts +21 -8
- package/src/memory/contracts.ts +31 -0
- package/src/memory/memory-coordinator.ts +7 -0
- package/src/memory/memory-extractor.ts +26 -5
- package/src/model/fake-model-client.ts +7 -0
- package/src/model/model-client.ts +11 -0
- package/src/model/openai-chat-model-client.ts +7 -1
- package/src/model/openai-responses-model-client.ts +8 -1
- package/src/session/resume-projection.ts +8 -6
- package/src/session/session-catalog.ts +18 -3
- package/src/session/session-last-response-reader.ts +10 -6
- package/src/session/session-store.ts +40 -8
- package/src/session/workspace-storage.ts +84 -0
- package/src/tools/bash-task.ts +14 -12
- package/src/tools/registry.ts +2 -0
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.
|
|
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
|
-
###
|
|
462
|
-
|
|
463
|
-
Tinker keeps private runtime state
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
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
|
@@ -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: (
|
|
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({
|
|
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(
|
|
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(
|
|
2949
|
+
input.persistence?.eventLogPath ?? path.join(sessionDirectory, "events.jsonl"),
|
|
2943
2950
|
),
|
|
2944
2951
|
new ObservationTextLog(
|
|
2945
|
-
input.persistence?.observationLogPath ??
|
|
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
|
|
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
|
-
|
|
237
|
-
): string {
|
|
238
|
-
return path.join(
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
}
|
package/src/cli/tui-runner.tsx
CHANGED
|
@@ -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(
|
|
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
|
|
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(
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
292
|
-
await chmod(current, 0o700);
|
|
293
|
-
}
|
|
306
|
+
await chmod(current, 0o700);
|
|
294
307
|
}
|
|
295
308
|
const canonical = await realpath(current);
|
|
296
|
-
assertContained(
|
|
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
|
}
|
package/src/memory/contracts.ts
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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 {
|
|
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 =
|
|
58
|
+
const databasePath = await resolveSessionDatabasePath(
|
|
55
59
|
workspaceRoot,
|
|
56
|
-
".tinker",
|
|
57
|
-
"sessions",
|
|
58
60
|
input.sessionId,
|
|
59
|
-
|
|
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(
|
|
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 =
|
|
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(
|
|
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,
|
|
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(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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(
|
|
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({
|
|
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
|
|
3825
|
+
export async function resolveSessionDatabasePath(
|
|
3806
3826
|
workspaceRoot: string,
|
|
3807
3827
|
sessionId: SessionId,
|
|
3808
|
-
|
|
3809
|
-
|
|
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(
|
|
6085
|
-
|
|
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
|
+
}
|
package/src/tools/bash-task.ts
CHANGED
|
@@ -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
|
|
144
|
-
|
|
145
|
-
|
|
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);
|
package/src/tools/registry.ts
CHANGED
|
@@ -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(
|