tinker-agent 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +36 -1
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/agent/loop.ts +1 -1
- package/src/agent/runtime-session.ts +279 -28
- package/src/agent/session-ledger.ts +10 -3
- package/src/cli/config.ts +8 -12
- package/src/cli/tui-runner.tsx +3 -1
- package/src/context/context-manager.ts +185 -8
- package/src/context/context-policy.ts +1 -1
- package/src/context/context-revision-compiler.ts +7 -2
- package/src/context/context-swap-label.ts +132 -0
- package/src/context/swap-planner.ts +219 -56
- package/src/events/stdout-event-printer.ts +1 -0
- package/src/events/types.ts +3 -3
- 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/observation/observation-builder.ts +36 -0
- 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 +340 -8
- package/src/session/workspace-storage.ts +84 -0
- package/src/tools/bash-task.ts +14 -12
- package/src/tools/context-maintenance.ts +266 -0
- package/src/tools/registry.ts +20 -1
- package/src/tools/types.ts +83 -1
- package/src/tui/event-store.ts +15 -0
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "node:fs/promises";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { Database } from "bun:sqlite";
|
|
16
|
+
import { parseMessageId } from "../ids/runtime-id";
|
|
16
17
|
import type {
|
|
17
18
|
ContextRevisionId,
|
|
18
19
|
ContextSurfaceId,
|
|
@@ -34,6 +35,11 @@ import {
|
|
|
34
35
|
} from "../model/model-client";
|
|
35
36
|
import type { ToolDefinition, ToolRawResult } from "../tools/types";
|
|
36
37
|
import { sha256, stableJsonStringify } from "../model/model-request-preflight";
|
|
38
|
+
import {
|
|
39
|
+
canonicalHomeRoot,
|
|
40
|
+
resolveWorkspaceStorageRoot,
|
|
41
|
+
workspaceStorageRoot,
|
|
42
|
+
} from "./workspace-storage";
|
|
37
43
|
import {
|
|
38
44
|
ContextProtocolError,
|
|
39
45
|
ContextProtocolValidator,
|
|
@@ -418,6 +424,7 @@ export type CreateNewSessionStoreInput = {
|
|
|
418
424
|
projectInstruction?: ProjectInstructionManifest;
|
|
419
425
|
idFactory: RuntimeIdFactory;
|
|
420
426
|
clock?: () => string;
|
|
427
|
+
homeRoot?: string;
|
|
421
428
|
};
|
|
422
429
|
|
|
423
430
|
export type OpenSessionStoreInput = {
|
|
@@ -425,6 +432,7 @@ export type OpenSessionStoreInput = {
|
|
|
425
432
|
sessionId: SessionId;
|
|
426
433
|
clock?: () => string;
|
|
427
434
|
allowIncomplete?: boolean;
|
|
435
|
+
homeRoot?: string;
|
|
428
436
|
};
|
|
429
437
|
|
|
430
438
|
export class SessionStore implements SessionLedgerCommitter {
|
|
@@ -447,6 +455,7 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
447
455
|
sessionDirectory: string;
|
|
448
456
|
databasePath: string;
|
|
449
457
|
clock: () => string;
|
|
458
|
+
homeRoot?: string;
|
|
450
459
|
},
|
|
451
460
|
) {
|
|
452
461
|
this.sessionId = input.sessionId;
|
|
@@ -454,14 +463,17 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
454
463
|
this.sessionDirectory = input.sessionDirectory;
|
|
455
464
|
this.databasePath = input.databasePath;
|
|
456
465
|
this.clock = input.clock;
|
|
466
|
+
this.homeRoot = input.homeRoot;
|
|
457
467
|
}
|
|
458
468
|
|
|
469
|
+
private readonly homeRoot?: string;
|
|
470
|
+
|
|
459
471
|
private readonly clock: () => string;
|
|
460
472
|
|
|
461
473
|
static async createNew(input: CreateNewSessionStoreInput): Promise<SessionStore> {
|
|
462
474
|
const clock = input.clock ?? (() => new Date().toISOString());
|
|
463
475
|
const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
|
|
464
|
-
const sessionsRoot = await ensureSessionsRoot(workspaceRoot);
|
|
476
|
+
const sessionsRoot = await ensureSessionsRoot(workspaceRoot, input.homeRoot);
|
|
465
477
|
const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
|
|
466
478
|
try {
|
|
467
479
|
await mkdir(sessionDirectory, { mode: 0o700 });
|
|
@@ -540,6 +552,7 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
540
552
|
sessionDirectory,
|
|
541
553
|
databasePath,
|
|
542
554
|
clock,
|
|
555
|
+
...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
|
|
543
556
|
});
|
|
544
557
|
await store.correctDatabaseModes();
|
|
545
558
|
store.validateCreatingState();
|
|
@@ -558,7 +571,10 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
558
571
|
static async openExisting(input: OpenSessionStoreInput): Promise<SessionStore> {
|
|
559
572
|
const clock = input.clock ?? (() => new Date().toISOString());
|
|
560
573
|
const workspaceRoot = await canonicalWorkspaceRoot(input.workspaceRoot);
|
|
561
|
-
const sessionsRoot = path.join(
|
|
574
|
+
const sessionsRoot = path.join(
|
|
575
|
+
workspaceStorageRoot(workspaceRoot, await canonicalHomeRoot(input.homeRoot)),
|
|
576
|
+
"sessions",
|
|
577
|
+
);
|
|
562
578
|
await validateSessionsRoot(sessionsRoot, input.sessionId);
|
|
563
579
|
const sessionDirectory = safeSessionDirectory(sessionsRoot, input.sessionId);
|
|
564
580
|
await validateSecureDirectory(sessionDirectory, input.sessionId);
|
|
@@ -593,6 +609,7 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
593
609
|
sessionDirectory,
|
|
594
610
|
databasePath,
|
|
595
611
|
clock,
|
|
612
|
+
...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
|
|
596
613
|
});
|
|
597
614
|
store.recallIndexRebuilt = recallIndexContractUpgraded;
|
|
598
615
|
const meta = store.readMeta();
|
|
@@ -2366,7 +2383,10 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
2366
2383
|
if (distinct.size === 0) {
|
|
2367
2384
|
return;
|
|
2368
2385
|
}
|
|
2369
|
-
const store = await ImageAssetStore.open({
|
|
2386
|
+
const store = await ImageAssetStore.open({
|
|
2387
|
+
workspaceRoot: this.workspaceRoot,
|
|
2388
|
+
...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
|
|
2389
|
+
});
|
|
2370
2390
|
for (const asset of distinct.values()) {
|
|
2371
2391
|
await store.verify(asset);
|
|
2372
2392
|
}
|
|
@@ -2670,6 +2690,7 @@ export class SessionStore implements SessionLedgerCommitter {
|
|
|
2670
2690
|
sessionDirectory: stagingDirectory,
|
|
2671
2691
|
databasePath: stagingDatabasePath,
|
|
2672
2692
|
clock: this.clock,
|
|
2693
|
+
...(this.homeRoot === undefined ? {} : { homeRoot: this.homeRoot }),
|
|
2673
2694
|
});
|
|
2674
2695
|
clonedStore.validateAll({ allowOpenTail: false });
|
|
2675
2696
|
await clonedStore.verifyImageAssetFiles();
|
|
@@ -3802,11 +3823,17 @@ function normalizeInputModalities(
|
|
|
3802
3823
|
);
|
|
3803
3824
|
}
|
|
3804
3825
|
|
|
3805
|
-
export function
|
|
3826
|
+
export async function resolveSessionDatabasePath(
|
|
3806
3827
|
workspaceRoot: string,
|
|
3807
3828
|
sessionId: SessionId,
|
|
3808
|
-
|
|
3809
|
-
|
|
3829
|
+
homeRoot?: string,
|
|
3830
|
+
): Promise<string> {
|
|
3831
|
+
return path.join(
|
|
3832
|
+
await resolveWorkspaceStorageRoot(workspaceRoot, homeRoot),
|
|
3833
|
+
"sessions",
|
|
3834
|
+
sessionId,
|
|
3835
|
+
"session.sqlite",
|
|
3836
|
+
);
|
|
3810
3837
|
}
|
|
3811
3838
|
|
|
3812
3839
|
function insertFrame(database: Database, frame: ProtocolFrame): void {
|
|
@@ -5070,6 +5097,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
|
|
|
5070
5097
|
"web_search",
|
|
5071
5098
|
"web_fetch",
|
|
5072
5099
|
"recall",
|
|
5100
|
+
"context_maintenance",
|
|
5073
5101
|
"memory_search",
|
|
5074
5102
|
"memory_get",
|
|
5075
5103
|
"wait",
|
|
@@ -5088,9 +5116,282 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
|
|
|
5088
5116
|
if (kind === "view_image") {
|
|
5089
5117
|
return decodeStoredViewImageRawResult(raw);
|
|
5090
5118
|
}
|
|
5119
|
+
if (kind === "context_maintenance") {
|
|
5120
|
+
return decodeStoredContextMaintenanceRawResult(raw);
|
|
5121
|
+
}
|
|
5091
5122
|
return immutableCanonicalClone(raw) as ToolRawResult;
|
|
5092
5123
|
}
|
|
5093
5124
|
|
|
5125
|
+
function decodeStoredContextMaintenanceRawResult(
|
|
5126
|
+
raw: Record<string, unknown>,
|
|
5127
|
+
): Extract<ToolRawResult, { kind: "context_maintenance" }> {
|
|
5128
|
+
const operation = enumFromSql(
|
|
5129
|
+
raw.operation,
|
|
5130
|
+
["status", "candidates", "swap"] as const,
|
|
5131
|
+
"context maintenance operation",
|
|
5132
|
+
);
|
|
5133
|
+
if (raw.ok === false) {
|
|
5134
|
+
if (operation !== "swap") {
|
|
5135
|
+
assertObjectKeys(
|
|
5136
|
+
raw,
|
|
5137
|
+
["kind", "ok", "operation", "error"],
|
|
5138
|
+
["kind", "ok", "operation", "error"],
|
|
5139
|
+
`failed context ${operation} result`,
|
|
5140
|
+
);
|
|
5141
|
+
return immutableRecord({
|
|
5142
|
+
kind: "context_maintenance" as const,
|
|
5143
|
+
ok: false as const,
|
|
5144
|
+
operation,
|
|
5145
|
+
error: nonEmptyStringFromJson(raw.error, `context ${operation} error`),
|
|
5146
|
+
});
|
|
5147
|
+
}
|
|
5148
|
+
assertObjectKeys(
|
|
5149
|
+
raw,
|
|
5150
|
+
["kind", "ok", "operation", "scheduled", "rejected", "error"],
|
|
5151
|
+
["kind", "ok", "operation", "scheduled", "rejected"],
|
|
5152
|
+
"failed context swap result",
|
|
5153
|
+
);
|
|
5154
|
+
if (!Array.isArray(raw.scheduled) || raw.scheduled.length !== 0) {
|
|
5155
|
+
throw new Error("Failed context swap result must schedule no candidates.");
|
|
5156
|
+
}
|
|
5157
|
+
const rejected = decodeContextSwapRejected(raw.rejected);
|
|
5158
|
+
const error =
|
|
5159
|
+
raw.error === undefined
|
|
5160
|
+
? undefined
|
|
5161
|
+
: nonEmptyStringFromJson(raw.error, "context swap error");
|
|
5162
|
+
if (rejected.length === 0 && error === undefined) {
|
|
5163
|
+
throw new Error("Failed context swap result must explain its failure.");
|
|
5164
|
+
}
|
|
5165
|
+
return immutableRecord({
|
|
5166
|
+
kind: "context_maintenance" as const,
|
|
5167
|
+
ok: false as const,
|
|
5168
|
+
operation,
|
|
5169
|
+
scheduled: Object.freeze([]),
|
|
5170
|
+
rejected,
|
|
5171
|
+
...(error === undefined ? {} : { error }),
|
|
5172
|
+
});
|
|
5173
|
+
}
|
|
5174
|
+
if (raw.ok !== true) {
|
|
5175
|
+
throw new Error("Context maintenance raw result ok must be a boolean.");
|
|
5176
|
+
}
|
|
5177
|
+
if (operation === "status") {
|
|
5178
|
+
assertObjectKeys(
|
|
5179
|
+
raw,
|
|
5180
|
+
[
|
|
5181
|
+
"kind",
|
|
5182
|
+
"ok",
|
|
5183
|
+
"operation",
|
|
5184
|
+
"usedInputTokens",
|
|
5185
|
+
"inputBudgetTokens",
|
|
5186
|
+
"pressure",
|
|
5187
|
+
"triggerTokens",
|
|
5188
|
+
"source",
|
|
5189
|
+
],
|
|
5190
|
+
[
|
|
5191
|
+
"kind",
|
|
5192
|
+
"ok",
|
|
5193
|
+
"operation",
|
|
5194
|
+
"usedInputTokens",
|
|
5195
|
+
"inputBudgetTokens",
|
|
5196
|
+
"pressure",
|
|
5197
|
+
"triggerTokens",
|
|
5198
|
+
"source",
|
|
5199
|
+
],
|
|
5200
|
+
"context status result",
|
|
5201
|
+
);
|
|
5202
|
+
return immutableRecord({
|
|
5203
|
+
kind: "context_maintenance" as const,
|
|
5204
|
+
ok: true as const,
|
|
5205
|
+
operation,
|
|
5206
|
+
usedInputTokens: nonNegativeJsonInteger(
|
|
5207
|
+
raw.usedInputTokens,
|
|
5208
|
+
"context status usedInputTokens",
|
|
5209
|
+
),
|
|
5210
|
+
inputBudgetTokens: positiveJsonInteger(
|
|
5211
|
+
raw.inputBudgetTokens,
|
|
5212
|
+
"context status inputBudgetTokens",
|
|
5213
|
+
),
|
|
5214
|
+
pressure: enumFromSql(
|
|
5215
|
+
raw.pressure,
|
|
5216
|
+
["normal", "high", "critical"] as const,
|
|
5217
|
+
"context status pressure",
|
|
5218
|
+
),
|
|
5219
|
+
triggerTokens: positiveJsonInteger(
|
|
5220
|
+
raw.triggerTokens,
|
|
5221
|
+
"context status triggerTokens",
|
|
5222
|
+
),
|
|
5223
|
+
source: enumFromSql(
|
|
5224
|
+
raw.source,
|
|
5225
|
+
[
|
|
5226
|
+
"estimated_full",
|
|
5227
|
+
"provider_measured",
|
|
5228
|
+
"measured_plus_estimated_delta",
|
|
5229
|
+
] as const,
|
|
5230
|
+
"context status source",
|
|
5231
|
+
),
|
|
5232
|
+
});
|
|
5233
|
+
}
|
|
5234
|
+
if (operation === "candidates") {
|
|
5235
|
+
assertObjectKeys(
|
|
5236
|
+
raw,
|
|
5237
|
+
["kind", "ok", "operation", "total", "candidates"],
|
|
5238
|
+
["kind", "ok", "operation", "total", "candidates"],
|
|
5239
|
+
"context swap candidates result",
|
|
5240
|
+
);
|
|
5241
|
+
if (!Array.isArray(raw.candidates) || raw.candidates.length > 50) {
|
|
5242
|
+
throw new Error("Context swap candidates result has an invalid page.");
|
|
5243
|
+
}
|
|
5244
|
+
const candidates = raw.candidates.map((value, index) => {
|
|
5245
|
+
const candidate = recordFromSql(value, `context candidate ${index}`);
|
|
5246
|
+
assertObjectKeys(
|
|
5247
|
+
candidate,
|
|
5248
|
+
["candidateId", "label", "ordinal", "savingsBytes"],
|
|
5249
|
+
["candidateId", "label", "ordinal", "savingsBytes"],
|
|
5250
|
+
`context candidate ${index}`,
|
|
5251
|
+
);
|
|
5252
|
+
const label = stringFromSql(candidate.label, `context candidate ${index} label`);
|
|
5253
|
+
if (
|
|
5254
|
+
label === "" ||
|
|
5255
|
+
label !== label.replace(/[\p{Cc}\p{Cf}\s]+/gu, " ").trim() ||
|
|
5256
|
+
Buffer.byteLength(label, "utf8") > 80
|
|
5257
|
+
) {
|
|
5258
|
+
throw new Error(`Context candidate ${index} label is invalid or too large.`);
|
|
5259
|
+
}
|
|
5260
|
+
return immutableRecord({
|
|
5261
|
+
candidateId: parseMessageId(
|
|
5262
|
+
stringFromSql(candidate.candidateId, `context candidate ${index} ID`),
|
|
5263
|
+
),
|
|
5264
|
+
label,
|
|
5265
|
+
ordinal: positiveJsonInteger(
|
|
5266
|
+
candidate.ordinal,
|
|
5267
|
+
`context candidate ${index} ordinal`,
|
|
5268
|
+
),
|
|
5269
|
+
savingsBytes: positiveJsonInteger(
|
|
5270
|
+
candidate.savingsBytes,
|
|
5271
|
+
`context candidate ${index} savingsBytes`,
|
|
5272
|
+
),
|
|
5273
|
+
});
|
|
5274
|
+
});
|
|
5275
|
+
if (
|
|
5276
|
+
new Set(candidates.map((candidate) => candidate.candidateId)).size !==
|
|
5277
|
+
candidates.length ||
|
|
5278
|
+
candidates.some(
|
|
5279
|
+
(candidate, index) =>
|
|
5280
|
+
index > 0 &&
|
|
5281
|
+
candidate.ordinal <= (candidates[index - 1]?.ordinal ?? candidate.ordinal),
|
|
5282
|
+
)
|
|
5283
|
+
) {
|
|
5284
|
+
throw new Error(
|
|
5285
|
+
"Context swap candidates must have unique IDs and ascending ordinals.",
|
|
5286
|
+
);
|
|
5287
|
+
}
|
|
5288
|
+
const total = nonNegativeJsonInteger(raw.total, "context candidates total");
|
|
5289
|
+
if (total < candidates.length) {
|
|
5290
|
+
throw new Error("Context candidates total is smaller than its page.");
|
|
5291
|
+
}
|
|
5292
|
+
return immutableRecord({
|
|
5293
|
+
kind: "context_maintenance" as const,
|
|
5294
|
+
ok: true as const,
|
|
5295
|
+
operation,
|
|
5296
|
+
total,
|
|
5297
|
+
candidates: Object.freeze(candidates),
|
|
5298
|
+
});
|
|
5299
|
+
}
|
|
5300
|
+
|
|
5301
|
+
assertObjectKeys(
|
|
5302
|
+
raw,
|
|
5303
|
+
["kind", "ok", "operation", "scheduled", "rejected", "note"],
|
|
5304
|
+
["kind", "ok", "operation", "scheduled", "rejected", "note"],
|
|
5305
|
+
"context swap result",
|
|
5306
|
+
);
|
|
5307
|
+
if (!Array.isArray(raw.scheduled) || raw.scheduled.length < 1) {
|
|
5308
|
+
throw new Error("Successful context swap result must schedule candidates.");
|
|
5309
|
+
}
|
|
5310
|
+
const scheduled = raw.scheduled.map((value, index) => {
|
|
5311
|
+
const candidate = recordFromSql(value, `scheduled context candidate ${index}`);
|
|
5312
|
+
assertObjectKeys(
|
|
5313
|
+
candidate,
|
|
5314
|
+
["candidateId", "savingsBytes"],
|
|
5315
|
+
["candidateId", "savingsBytes"],
|
|
5316
|
+
`scheduled context candidate ${index}`,
|
|
5317
|
+
);
|
|
5318
|
+
return immutableRecord({
|
|
5319
|
+
candidateId: parseMessageId(
|
|
5320
|
+
stringFromSql(candidate.candidateId, `scheduled candidate ${index} ID`),
|
|
5321
|
+
),
|
|
5322
|
+
savingsBytes: positiveJsonInteger(
|
|
5323
|
+
candidate.savingsBytes,
|
|
5324
|
+
`scheduled candidate ${index} savingsBytes`,
|
|
5325
|
+
),
|
|
5326
|
+
});
|
|
5327
|
+
});
|
|
5328
|
+
if (
|
|
5329
|
+
scheduled.length > 16 ||
|
|
5330
|
+
new Set(scheduled.map((candidate) => candidate.candidateId)).size !==
|
|
5331
|
+
scheduled.length
|
|
5332
|
+
) {
|
|
5333
|
+
throw new Error("Successful context swap result has invalid scheduled IDs.");
|
|
5334
|
+
}
|
|
5335
|
+
const rejected = decodeContextSwapRejected(raw.rejected);
|
|
5336
|
+
if (
|
|
5337
|
+
scheduled.length + rejected.length > 16 ||
|
|
5338
|
+
scheduled.some((scheduledCandidate) =>
|
|
5339
|
+
rejected.some(
|
|
5340
|
+
(rejectedCandidate) =>
|
|
5341
|
+
rejectedCandidate.candidateId === scheduledCandidate.candidateId,
|
|
5342
|
+
),
|
|
5343
|
+
)
|
|
5344
|
+
) {
|
|
5345
|
+
throw new Error("Context swap result candidate partitions are invalid.");
|
|
5346
|
+
}
|
|
5347
|
+
const note = stringFromSql(raw.note, "context swap note");
|
|
5348
|
+
return immutableRecord({
|
|
5349
|
+
kind: "context_maintenance" as const,
|
|
5350
|
+
ok: true as const,
|
|
5351
|
+
operation,
|
|
5352
|
+
scheduled: Object.freeze(scheduled),
|
|
5353
|
+
rejected,
|
|
5354
|
+
note,
|
|
5355
|
+
});
|
|
5356
|
+
}
|
|
5357
|
+
|
|
5358
|
+
function decodeContextSwapRejected(value: unknown): readonly {
|
|
5359
|
+
readonly candidateId: MessageId;
|
|
5360
|
+
readonly reason: string;
|
|
5361
|
+
}[] {
|
|
5362
|
+
if (!Array.isArray(value) || value.length > 16) {
|
|
5363
|
+
throw new Error("Context swap rejected candidates must be an array of at most 16.");
|
|
5364
|
+
}
|
|
5365
|
+
const rejected = value.map((entry, index) => {
|
|
5366
|
+
const candidate = recordFromSql(entry, `rejected context candidate ${index}`);
|
|
5367
|
+
assertObjectKeys(
|
|
5368
|
+
candidate,
|
|
5369
|
+
["candidateId", "reason"],
|
|
5370
|
+
["candidateId", "reason"],
|
|
5371
|
+
`rejected context candidate ${index}`,
|
|
5372
|
+
);
|
|
5373
|
+
const reason = stringFromSql(
|
|
5374
|
+
candidate.reason,
|
|
5375
|
+
`rejected context candidate ${index} reason`,
|
|
5376
|
+
);
|
|
5377
|
+
if (!/^[a-z][a-z0-9_]{0,79}$/.test(reason)) {
|
|
5378
|
+
throw new Error(`Rejected context candidate ${index} reason is invalid.`);
|
|
5379
|
+
}
|
|
5380
|
+
return immutableRecord({
|
|
5381
|
+
candidateId: parseMessageId(
|
|
5382
|
+
stringFromSql(candidate.candidateId, `rejected candidate ${index} ID`),
|
|
5383
|
+
),
|
|
5384
|
+
reason,
|
|
5385
|
+
});
|
|
5386
|
+
});
|
|
5387
|
+
if (
|
|
5388
|
+
new Set(rejected.map((candidate) => candidate.candidateId)).size !== rejected.length
|
|
5389
|
+
) {
|
|
5390
|
+
throw new Error("Context swap rejected candidate IDs must be unique.");
|
|
5391
|
+
}
|
|
5392
|
+
return Object.freeze(rejected);
|
|
5393
|
+
}
|
|
5394
|
+
|
|
5094
5395
|
function decodeStoredViewImageRawResult(
|
|
5095
5396
|
raw: Record<string, unknown>,
|
|
5096
5397
|
): Extract<ToolRawResult, { kind: "view_image" }> {
|
|
@@ -6081,8 +6382,14 @@ async function canonicalWorkspaceRoot(workspaceRoot: string): Promise<string> {
|
|
|
6081
6382
|
return realpath(workspaceRoot);
|
|
6082
6383
|
}
|
|
6083
6384
|
|
|
6084
|
-
async function ensureSessionsRoot(
|
|
6085
|
-
|
|
6385
|
+
async function ensureSessionsRoot(
|
|
6386
|
+
workspaceRoot: string,
|
|
6387
|
+
homeRoot?: string,
|
|
6388
|
+
): Promise<string> {
|
|
6389
|
+
const tinkerRoot = workspaceStorageRoot(
|
|
6390
|
+
workspaceRoot,
|
|
6391
|
+
await canonicalHomeRoot(homeRoot),
|
|
6392
|
+
);
|
|
6086
6393
|
const sessionsRoot = path.join(tinkerRoot, "sessions");
|
|
6087
6394
|
await mkdir(sessionsRoot, { recursive: true, mode: 0o700 });
|
|
6088
6395
|
await validateSessionsRoot(sessionsRoot);
|
|
@@ -6398,6 +6705,31 @@ function numberFromJson(value: unknown, name: string): number {
|
|
|
6398
6705
|
return value as number;
|
|
6399
6706
|
}
|
|
6400
6707
|
|
|
6708
|
+
function safeJsonInteger(value: unknown, name: string): number {
|
|
6709
|
+
if (!Number.isSafeInteger(value)) {
|
|
6710
|
+
throw new Error(`${name} must be a safe integer.`);
|
|
6711
|
+
}
|
|
6712
|
+
return value as number;
|
|
6713
|
+
}
|
|
6714
|
+
|
|
6715
|
+
function nonNegativeJsonInteger(value: unknown, name: string): number {
|
|
6716
|
+
const number = safeJsonInteger(value, name);
|
|
6717
|
+
if (number < 0) throw new Error(`${name} must be non-negative.`);
|
|
6718
|
+
return number;
|
|
6719
|
+
}
|
|
6720
|
+
|
|
6721
|
+
function positiveJsonInteger(value: unknown, name: string): number {
|
|
6722
|
+
const number = safeJsonInteger(value, name);
|
|
6723
|
+
if (number < 1) throw new Error(`${name} must be positive.`);
|
|
6724
|
+
return number;
|
|
6725
|
+
}
|
|
6726
|
+
|
|
6727
|
+
function nonEmptyStringFromJson(value: unknown, name: string): string {
|
|
6728
|
+
const text = stringFromSql(value, name);
|
|
6729
|
+
if (text.trim() === "") throw new Error(`${name} must not be empty.`);
|
|
6730
|
+
return text;
|
|
6731
|
+
}
|
|
6732
|
+
|
|
6401
6733
|
function enumFromSql<const T extends readonly string[]>(
|
|
6402
6734
|
value: unknown,
|
|
6403
6735
|
values: T,
|
|
@@ -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);
|