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 CHANGED
@@ -5,6 +5,39 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.4.0] - 2026-09-02
9
+
10
+ ### Added
11
+
12
+ - Add model-directed context management. Three new built-in tools open context
13
+ maintenance to the model itself: `ContextStatus` reports live input-token
14
+ usage and pressure, `ContextSwapCandidates` lists swappable historical tool
15
+ observations with short labels and byte savings, and `ContextSwap` schedules
16
+ selected observations for eviction. Scheduled swaps are validated immediately
17
+ and committed as one context revision when the iteration's tool frames close,
18
+ leaving Recall-recoverable placeholders behind. A one-shot lease pauses
19
+ automatic compaction for exactly one iteration after a candidate listing so
20
+ the model keeps the choice under real pressure.
21
+
22
+ ### Changed
23
+
24
+ - Lower the swap eligibility floor from 8 KiB to 2 KiB per observation. Both
25
+ automatic compaction and model-directed swaps can now evict medium-sized
26
+ tool observations, individually or in batches of up to 16 per swap.
27
+
28
+ ## [2.3.0] - 2026-08-31
29
+
30
+ ### Changed
31
+
32
+ - Store per-workspace runtime state in the global Tinker home instead of inside
33
+ the project. Sessions (`sessions/<id>/`), background Bash logs (`bash/`),
34
+ image assets (`assets/images/`), and prompt history now live under
35
+ `~/.tinker/projects/<workspace-slug-hash>/`, keyed by the workspace's
36
+ canonical absolute path. Projects no longer grow a `.tinker/` directory.
37
+ Existing in-project `.tinker/` state is left in place but no longer read.
38
+ - Add `TINKER_HOME` to relocate the global Tinker home directory (default: the
39
+ OS home directory).
40
+
8
41
  ## [2.2.0] - 2026-08-29
9
42
 
10
43
  ### Added
@@ -264,7 +297,9 @@ All notable user-facing changes to Tinker are documented here. The project follo
264
297
  - First formal npm release under the `tinker-agent` package name with the `tinker`
265
298
  executable.
266
299
 
267
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.2.0...HEAD
300
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.4.0...HEAD
301
+ [2.4.0]: https://github.com/ishowshao/tinker/releases/tag/v2.4.0
302
+ [2.3.0]: https://github.com/ishowshao/tinker/releases/tag/v2.3.0
268
303
  [2.2.0]: https://github.com/ishowshao/tinker/releases/tag/v2.2.0
269
304
  [2.1.0]: https://github.com/ishowshao/tinker/releases/tag/v2.1.0
270
305
  [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.4.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",
package/src/agent/loop.ts CHANGED
@@ -56,7 +56,7 @@ export type RunAgentInput = {
56
56
  preflight: ReturnType<ContextMeter["measure"]>;
57
57
  }):
58
58
  | {
59
- trigger: Exclude<SwapPlanningTrigger, "manual">;
59
+ trigger: Exclude<SwapPlanningTrigger, "manual" | "model_directed">;
60
60
  forcedTargetTokens?: number;
61
61
  }
62
62
  | undefined;
@@ -11,6 +11,7 @@ import type {
11
11
  } from "../events/types";
12
12
  import {
13
13
  runtimeIdFactory,
14
+ type MessageId,
14
15
  type RuntimeIdFactory,
15
16
  type SessionId,
16
17
  type TurnId,
@@ -27,6 +28,7 @@ import {
27
28
  type MaterializedModelRequest,
28
29
  type ModelClient,
29
30
  } from "../model/model-client";
31
+ import type { ContextPressure } from "../model/model-request-preflight";
30
32
  import { ImageAssetStore, type ImportedImageAsset } from "../image/image-asset-store";
31
33
  import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
32
34
  import {
@@ -72,7 +74,14 @@ import {
72
74
  renderedMessageHash,
73
75
  } from "../context/compiled-context-hash";
74
76
  import { createDefaultTooling, type DefaultTooling } from "../tools/registry";
75
- import type { ToolExecutor } from "../tools/types";
77
+ import {
78
+ ToolExecutionFatalError,
79
+ type ContextMaintenanceHandle,
80
+ type ContextStatusRawResult,
81
+ type ContextSwapCandidatesRawResult,
82
+ type ContextSwapRawResult,
83
+ type ToolExecutor,
84
+ } from "../tools/types";
76
85
  import type { TurnUndoResult } from "../tools/turn-undo-manager";
77
86
  import type { Refiner } from "../tools/web-fetch/refiner";
78
87
  import type { ProjectInstructionManifest } from "../instructions/project-instructions";
@@ -105,6 +114,7 @@ import type { PublicToolingConfig } from "../cli/public-config-contract";
105
114
  import type {
106
115
  IterationIdentity,
107
116
  RunAgentResult,
117
+ ToolCall,
108
118
  ToolCallIdentity,
109
119
  TurnIdentity,
110
120
  } from "./types";
@@ -221,6 +231,7 @@ export type RuntimeSkillsSnapshot = {
221
231
 
222
232
  export type RuntimeSessionContext = {
223
233
  readonly sessionId: SessionId;
234
+ readonly contextMaintenance: ContextMaintenanceHandle;
224
235
  createIteration(turn: TurnIdentity, iterationNumber: number): IterationIdentity;
225
236
  createToolCall(
226
237
  iteration: IterationIdentity,
@@ -287,6 +298,7 @@ export type CompletedTurnHook = {
287
298
 
288
299
  type CommonRuntimeSessionInput = {
289
300
  workspaceRoot: string;
301
+ homeRoot?: string;
290
302
  modelName: string;
291
303
  profileName?: string;
292
304
  maxIterations: number;
@@ -341,7 +353,10 @@ export type RuntimeSessionFactoryDependencies = {
341
353
  idFactory: RuntimeIdFactory,
342
354
  ) => Promise<SessionStore>;
343
355
  createLedger: (store: SessionStore, idFactory: RuntimeIdFactory) => SessionLedger;
344
- createEventSink: (input: CreateRuntimeSessionInput) => EventSink;
356
+ createEventSink: (
357
+ input: CreateRuntimeSessionInput,
358
+ sessionDirectory: string,
359
+ ) => EventSink;
345
360
  selectShadowPlanning: NonNullable<RunAgentInput["shadowPlanning"]>["select"];
346
361
  onShadowPlanningResult?: NonNullable<RunAgentInput["shadowPlanning"]>["onResult"];
347
362
  selectContextAutomation: typeof selectContextAutomation;
@@ -375,6 +390,8 @@ type RuntimeSessionState =
375
390
 
376
391
  type ActiveTurn = {
377
392
  turn: TurnIdentity;
393
+ ledger: AgentTurnLedger;
394
+ consumedThroughOrdinal?: number;
378
395
  controller: AbortController;
379
396
  completion: Promise<RunAgentResult>;
380
397
  };
@@ -407,10 +424,12 @@ const defaultDependencies: RuntimeSessionFactoryDependencies = {
407
424
  systemPrompt: input.systemPrompt,
408
425
  projectInstruction: input.projectInstruction,
409
426
  idFactory,
427
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
410
428
  })
411
429
  : SessionStore.openExisting({
412
430
  workspaceRoot: input.workspaceRoot,
413
431
  sessionId: input.selection.sessionId,
432
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
414
433
  }),
415
434
  createLedger: (store, idFactory) => new SqliteSessionLedger(store, idFactory),
416
435
  createEventSink,
@@ -461,6 +480,8 @@ class DefaultRuntimeSession implements RuntimeSession {
461
480
  private contextManager?: ContextManager;
462
481
  private contextAutomationDecision?: ContextAutomationDecision;
463
482
  private pendingAutomaticContextMaintenance = false;
483
+ private pendingModelDirectedSwap?: Set<MessageId>;
484
+ private modelDirectedSwapLease = false;
464
485
  private readonly skillCatalog: SkillCatalogSnapshot;
465
486
  private skillCoordinator = new SkillActivationCoordinator();
466
487
  private bashGuardMode: "guard" | "yolo";
@@ -511,6 +532,11 @@ class DefaultRuntimeSession implements RuntimeSession {
511
532
  this.shadowPlanner = new SwapPlanner(input.modelClient);
512
533
  this.context = {
513
534
  sessionId: this.sessionId,
535
+ contextMaintenance: {
536
+ status: (call) => this.contextStatus(call),
537
+ candidates: (call, page) => this.contextSwapCandidates(call, page),
538
+ swap: (call, selection) => this.contextSwap(call, selection),
539
+ },
514
540
  createIteration: (turn, iterationNumber) =>
515
541
  this.createIteration(turn, iterationNumber),
516
542
  createToolCall: (iteration, toolCallNumber) =>
@@ -541,7 +567,10 @@ class DefaultRuntimeSession implements RuntimeSession {
541
567
  const store = await dependencies.openStore(input, dependencies.idFactory);
542
568
  let assetStore: ImageAssetStore;
543
569
  try {
544
- assetStore = await ImageAssetStore.open({ workspaceRoot: store.workspaceRoot });
570
+ assetStore = await ImageAssetStore.open({
571
+ workspaceRoot: store.workspaceRoot,
572
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
573
+ });
545
574
  } catch (error) {
546
575
  if (isNewSessionInput(input)) {
547
576
  await store
@@ -557,7 +586,7 @@ class DefaultRuntimeSession implements RuntimeSession {
557
586
  session = new DefaultRuntimeSession(
558
587
  input,
559
588
  dependencies,
560
- dependencies.createEventSink(input),
589
+ dependencies.createEventSink(input, store.sessionDirectory),
561
590
  dependencies.createObservationBuilder(),
562
591
  store,
563
592
  assetStore,
@@ -654,6 +683,7 @@ class DefaultRuntimeSession implements RuntimeSession {
654
683
 
655
684
  session.tooling = dependencies.createTooling({
656
685
  workspaceRoot: input.workspaceRoot,
686
+ ...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
657
687
  runtimeSession: session.context,
658
688
  historyReader: store.historyReader(),
659
689
  imageAssetStore: assetStore,
@@ -1238,6 +1268,133 @@ class DefaultRuntimeSession implements RuntimeSession {
1238
1268
  return this.contextAutomationDecision;
1239
1269
  }
1240
1270
 
1271
+ private async contextStatus(call: ToolCall): Promise<ContextStatusRawResult> {
1272
+ const active = this.requireActiveContextTool(call, "ContextStatus");
1273
+ try {
1274
+ const usage = this.requireContextManager().measureActive(
1275
+ active.turn.turnId,
1276
+ active.ledger,
1277
+ );
1278
+ return Object.freeze({
1279
+ ok: true,
1280
+ operation: "status",
1281
+ usedInputTokens: usage.usedInputTokens,
1282
+ inputBudgetTokens: usage.inputBudgetTokens,
1283
+ pressure: toolContextPressure(usage.pressure),
1284
+ triggerTokens: usage.triggerTokens,
1285
+ source: usage.source,
1286
+ });
1287
+ } catch (error) {
1288
+ return {
1289
+ ok: false,
1290
+ operation: "status",
1291
+ error: this.contextToolFailure("status", error),
1292
+ };
1293
+ }
1294
+ }
1295
+
1296
+ private async contextSwapCandidates(
1297
+ call: ToolCall,
1298
+ page: { readonly limit: number; readonly offset: number },
1299
+ ): Promise<ContextSwapCandidatesRawResult> {
1300
+ const active = this.requireActiveContextTool(call, "ContextSwapCandidates");
1301
+ try {
1302
+ const result = this.requireContextManager().listActiveSwapCandidates({
1303
+ turnId: active.turn.turnId,
1304
+ consumedThroughOrdinal: active.consumedThroughOrdinal,
1305
+ activeLedger: active.ledger,
1306
+ limit: page.limit,
1307
+ offset: page.offset,
1308
+ });
1309
+ if (result.total > 0 && result.usage.pressure !== "normal") {
1310
+ this.modelDirectedSwapLease = true;
1311
+ }
1312
+ return Object.freeze({
1313
+ ok: true,
1314
+ operation: "candidates",
1315
+ total: result.total,
1316
+ candidates: result.candidates,
1317
+ });
1318
+ } catch (error) {
1319
+ return {
1320
+ ok: false,
1321
+ operation: "candidates",
1322
+ error: this.contextToolFailure("candidate listing", error),
1323
+ };
1324
+ }
1325
+ }
1326
+
1327
+ private async contextSwap(
1328
+ call: ToolCall,
1329
+ selection: { readonly candidateIds: readonly MessageId[] },
1330
+ ): Promise<ContextSwapRawResult> {
1331
+ const active = this.requireActiveContextTool(call, "ContextSwap");
1332
+ try {
1333
+ const result = this.requireContextManager().validateActiveSwapSelection({
1334
+ turnId: active.turn.turnId,
1335
+ consumedThroughOrdinal: active.consumedThroughOrdinal,
1336
+ activeLedger: active.ledger,
1337
+ messageIds: selection.candidateIds,
1338
+ });
1339
+ if (result.scheduled.length === 0) {
1340
+ return Object.freeze({
1341
+ ok: false,
1342
+ operation: "swap",
1343
+ scheduled: Object.freeze([]),
1344
+ rejected: result.rejected,
1345
+ });
1346
+ }
1347
+ const pending = (this.pendingModelDirectedSwap ??= new Set<MessageId>());
1348
+ for (const candidate of result.scheduled) pending.add(candidate.candidateId);
1349
+ this.modelDirectedSwapLease = false;
1350
+ return Object.freeze({
1351
+ ok: true,
1352
+ operation: "swap",
1353
+ scheduled: result.scheduled,
1354
+ rejected: result.rejected,
1355
+ note: "Swap executes when this iteration's tool frames close.",
1356
+ });
1357
+ } catch (error) {
1358
+ return {
1359
+ ok: false,
1360
+ operation: "swap",
1361
+ scheduled: [],
1362
+ rejected: [],
1363
+ error: this.contextToolFailure("swap scheduling", error),
1364
+ };
1365
+ }
1366
+ }
1367
+
1368
+ private requireActiveContextTool(
1369
+ call: ToolCall,
1370
+ expectedName: "ContextStatus" | "ContextSwapCandidates" | "ContextSwap",
1371
+ ): ActiveTurn & { consumedThroughOrdinal: number } {
1372
+ this.requireToolCall(call);
1373
+ const active = this.activeTurn;
1374
+ if (
1375
+ call.name !== expectedName ||
1376
+ active === undefined ||
1377
+ active.turn.turnId !== call.turnId ||
1378
+ active.consumedThroughOrdinal === undefined ||
1379
+ this.state !== "executing"
1380
+ ) {
1381
+ throw new ToolExecutionFatalError(
1382
+ `${expectedName} was called outside an active model iteration.`,
1383
+ );
1384
+ }
1385
+ return active as ActiveTurn & { consumedThroughOrdinal: number };
1386
+ }
1387
+
1388
+ private contextToolFailure(operation: string, error: unknown): string {
1389
+ if (error instanceof ContextManagerError && !error.fatal) {
1390
+ return `Context ${operation} failed (${boundedContextErrorCode(error.code)}).`;
1391
+ }
1392
+ throw new ToolExecutionFatalError(
1393
+ `Context ${operation} required canonical session state that could not be read safely.`,
1394
+ { cause: error },
1395
+ );
1396
+ }
1397
+
1241
1398
  private appendSkillsCatalogLoaded(): Promise<void> {
1242
1399
  const activeNames = this.skillCoordinator
1243
1400
  .activeEntries()
@@ -1297,6 +1454,15 @@ class DefaultRuntimeSession implements RuntimeSession {
1297
1454
  iteration: IterationIdentity;
1298
1455
  built: BuiltContextRequest;
1299
1456
  }): void {
1457
+ const active = this.activeTurn;
1458
+ if (
1459
+ active === undefined ||
1460
+ active.turn.turnId !== input.iteration.turnId ||
1461
+ active.turn.sessionId !== input.iteration.sessionId
1462
+ ) {
1463
+ throw new Error("Model dispatch does not belong to the active runtime turn.");
1464
+ }
1465
+ active.consumedThroughOrdinal = input.built.canonical.messages.length;
1300
1466
  const pending = this.store.loadSkillActivations(["pending"]);
1301
1467
  if (pending.length === 0) {
1302
1468
  return;
@@ -1665,7 +1831,12 @@ class DefaultRuntimeSession implements RuntimeSession {
1665
1831
  usage: admissionSnapshot,
1666
1832
  },
1667
1833
  });
1668
- this.activeTurn = { turn, controller, completion };
1834
+ this.activeTurn = {
1835
+ turn,
1836
+ ledger: pendingLedgerTurn.agent,
1837
+ controller,
1838
+ completion,
1839
+ };
1669
1840
  this.notifyPromptScheduler();
1670
1841
  return Object.freeze({
1671
1842
  turnId: turn.turnId,
@@ -2182,6 +2353,8 @@ class DefaultRuntimeSession implements RuntimeSession {
2182
2353
  this.activeTurn = undefined;
2183
2354
  this.notifyPromptScheduler();
2184
2355
  this.pendingAutomaticContextMaintenance = false;
2356
+ this.pendingModelDirectedSwap = undefined;
2357
+ this.modelDirectedSwapLease = false;
2185
2358
  if (this.state === "executing") {
2186
2359
  this.state = "ready";
2187
2360
  }
@@ -2219,28 +2392,48 @@ class DefaultRuntimeSession implements RuntimeSession {
2219
2392
  consumedThroughOrdinal: number;
2220
2393
  ledger: AgentTurnLedger;
2221
2394
  }): Promise<void> {
2222
- const automation = this.requireContextAutomation();
2223
- if (!automation.automaticSwapOnly) return;
2224
2395
  if (this.state !== "executing") {
2225
2396
  throw new Error(
2226
2397
  `Cannot maintain active-turn context while RuntimeSession is ${this.state}.`,
2227
2398
  );
2228
2399
  }
2229
- const manager = this.requireContextManager();
2230
- const usage = manager.measureCurrent(input.turn.turnId, input.ledger);
2231
- if (usage.pressure === "normal") return;
2400
+ const pendingModelDirectedSwap = this.pendingModelDirectedSwap;
2401
+ this.pendingModelDirectedSwap = undefined;
2402
+ if (pendingModelDirectedSwap === undefined && this.modelDirectedSwapLease) {
2403
+ this.modelDirectedSwapLease = false;
2404
+ return;
2405
+ }
2232
2406
 
2233
- const qualificationId = requireAutomationQualificationId(automation);
2234
- const compactionTrigger = {
2235
- kind: "runtime_pressure",
2236
- activeTurn: {
2237
- turnId: input.turn.turnId,
2238
- consumedThroughOrdinal: input.consumedThroughOrdinal,
2239
- },
2240
- } as const;
2407
+ const automation = this.requireContextAutomation();
2408
+ if (pendingModelDirectedSwap === undefined && !automation.automaticSwapOnly) {
2409
+ return;
2410
+ }
2411
+ const manager = this.requireContextManager();
2241
2412
  this.pendingAutomaticContextMaintenance = false;
2413
+ this.modelDirectedSwapLease = false;
2242
2414
  this.state = "maintaining_context";
2243
2415
  try {
2416
+ if (pendingModelDirectedSwap !== undefined) {
2417
+ await this.performModelDirectedCompaction({
2418
+ turn: input.turn,
2419
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
2420
+ ledger: input.ledger,
2421
+ messageIds: Object.freeze([...pendingModelDirectedSwap]),
2422
+ });
2423
+ }
2424
+ if (!automation.automaticSwapOnly) return;
2425
+
2426
+ const usage = manager.measureCurrent(input.turn.turnId, input.ledger);
2427
+ if (usage.pressure === "normal") return;
2428
+
2429
+ const qualificationId = requireAutomationQualificationId(automation);
2430
+ const compactionTrigger = {
2431
+ kind: "runtime_pressure",
2432
+ activeTurn: {
2433
+ turnId: input.turn.turnId,
2434
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
2435
+ },
2436
+ } as const;
2244
2437
  await this.append({
2245
2438
  type: "context.revision.started",
2246
2439
  sessionId: this.sessionId,
@@ -2337,6 +2530,56 @@ class DefaultRuntimeSession implements RuntimeSession {
2337
2530
  }
2338
2531
  }
2339
2532
 
2533
+ private async performModelDirectedCompaction(input: {
2534
+ turn: TurnIdentity;
2535
+ consumedThroughOrdinal: number;
2536
+ ledger: AgentTurnLedger;
2537
+ messageIds: readonly MessageId[];
2538
+ }): Promise<void> {
2539
+ await this.append({
2540
+ type: "context.revision.started",
2541
+ sessionId: this.sessionId,
2542
+ data: {
2543
+ strategy: "swap",
2544
+ reason: "model_directed",
2545
+ policyVersion: "swap-only-v1",
2546
+ rendererFormat: "swap-observation-v1",
2547
+ },
2548
+ });
2549
+ try {
2550
+ const result = await this.requireContextManager().compact(
2551
+ {
2552
+ kind: "model_directed",
2553
+ messageIds: input.messageIds,
2554
+ activeTurn: {
2555
+ turnId: input.turn.turnId,
2556
+ consumedThroughOrdinal: input.consumedThroughOrdinal,
2557
+ },
2558
+ },
2559
+ input.ledger,
2560
+ );
2561
+ await this.append({
2562
+ type: "context.revision.finished",
2563
+ sessionId: this.sessionId,
2564
+ data: contextRevisionFinishedData(result, "model_directed"),
2565
+ });
2566
+ } catch (error) {
2567
+ const failure = automaticContextFailure(error, "compaction");
2568
+ await this.append({
2569
+ type: "context.revision.failed",
2570
+ sessionId: this.sessionId,
2571
+ data: {
2572
+ strategy: "swap",
2573
+ reason: "model_directed",
2574
+ stage: failure.stage,
2575
+ errorCode: boundedContextErrorCode(failure.code),
2576
+ error: `Model-directed context compaction failed at ${failure.stage}.`,
2577
+ },
2578
+ }).catch(() => undefined);
2579
+ if (failure.fatal) throw error;
2580
+ }
2581
+ }
2582
+
2340
2583
  private notifyCompletedTurn(turn: TurnIdentity): void {
2341
2584
  const hook = this.input.completedTurnHook;
2342
2585
  if (hook === undefined) {
@@ -2928,21 +3171,19 @@ export async function createRuntimeSession(
2928
3171
  });
2929
3172
  }
2930
3173
 
2931
- function createEventSink(input: CreateRuntimeSessionInput): EventSink {
3174
+ function createEventSink(
3175
+ input: CreateRuntimeSessionInput,
3176
+ sessionDirectory: string,
3177
+ ): EventSink {
2932
3178
  const requiredSinks: EventSink[] = [];
2933
3179
  if (input.persistence !== false) {
2934
- const basePath = path.join(
2935
- input.workspaceRoot,
2936
- ".tinker",
2937
- "sessions",
2938
- input.selection.sessionId,
2939
- );
2940
3180
  requiredSinks.push(
2941
3181
  new JsonlEventLog(
2942
- input.persistence?.eventLogPath ?? path.join(basePath, "events.jsonl"),
3182
+ input.persistence?.eventLogPath ?? path.join(sessionDirectory, "events.jsonl"),
2943
3183
  ),
2944
3184
  new ObservationTextLog(
2945
- input.persistence?.observationLogPath ?? path.join(basePath, "observations.md"),
3185
+ input.persistence?.observationLogPath ??
3186
+ path.join(sessionDirectory, "observations.md"),
2946
3187
  ),
2947
3188
  );
2948
3189
  }
@@ -3078,7 +3319,7 @@ function errorMessage(error: unknown): string {
3078
3319
 
3079
3320
  function contextRevisionFinishedData(
3080
3321
  result: ContextCompactionResult,
3081
- reason: "manual" | "runtime_pressure" = "manual",
3322
+ reason: "manual" | "runtime_pressure" | "model_directed" = "manual",
3082
3323
  qualificationId?: string,
3083
3324
  ): ContextRevisionFinishedData {
3084
3325
  if (result.status === "unchanged") {
@@ -3216,6 +3457,16 @@ function boundedContextErrorCode(code: string): string {
3216
3457
  : "CONTEXT_COMPACTION_FAILED";
3217
3458
  }
3218
3459
 
3460
+ function toolContextPressure(
3461
+ pressure: ContextPressure,
3462
+ ): "normal" | "high" | "critical" {
3463
+ return pressure === "triggered"
3464
+ ? "high"
3465
+ : pressure === "blocked"
3466
+ ? "critical"
3467
+ : "normal";
3468
+ }
3469
+
3219
3470
  function requirePositiveNumber(value: number, name: string): void {
3220
3471
  if (!Number.isInteger(value) || value < 1) {
3221
3472
  throw new Error(`${name} must be a positive integer; received ${value}.`);
@@ -116,7 +116,10 @@ export type AgentTurnLedger = {
116
116
  commitToolCompletions(
117
117
  completions: readonly ToolCompletionInput[],
118
118
  ): readonly CommittedToolCompletion[];
119
- buildModelRequest(tools: readonly ToolDefinition[]): BuiltContextRequest;
119
+ buildModelRequest(
120
+ tools: readonly ToolDefinition[],
121
+ options?: { readonly allowOpenTail?: boolean },
122
+ ): BuiltContextRequest;
120
123
  activateContextSnapshot(snapshot: StoredContextSnapshotV8): void;
121
124
  };
122
125
 
@@ -657,9 +660,10 @@ export class InMemorySessionLedger implements SessionLedger {
657
660
  buildTurnModelRequest(
658
661
  pending: InMemoryPendingLedgerTurn,
659
662
  tools: readonly ToolDefinition[],
663
+ options: { readonly allowOpenTail?: boolean } = {},
660
664
  ): BuiltContextRequest {
661
665
  this.requirePending(pending, "build a model request");
662
- return this.buildRequest(tools);
666
+ return this.buildRequest(tools, undefined, options);
663
667
  }
664
668
 
665
669
  finishTurn(pending: InMemoryPendingLedgerTurn, result: RunAgentResult): void {
@@ -702,11 +706,13 @@ export class InMemorySessionLedger implements SessionLedger {
702
706
  private buildRequest(
703
707
  tools: readonly ToolDefinition[],
704
708
  candidateUserMessage?: UserMessage,
709
+ options: { readonly allowOpenTail?: boolean } = {},
705
710
  ): BuiltContextRequest {
706
711
  try {
707
712
  const canonical = this.view;
708
713
  const compiled = this.revisionCompiler.compileActive(
709
714
  snapshotFor(canonical, this.revision, this.surface, this.activeOverrides),
715
+ { allowOpenTail: options.allowOpenTail },
710
716
  );
711
717
  return this.contextBuilder.build({
712
718
  canonical,
@@ -865,7 +871,8 @@ class InMemoryPendingLedgerTurn implements PendingLedgerTurn {
865
871
  assertCanExecuteTool: (call) => this.ledger.assertCanExecuteTool(this, call),
866
872
  commitToolCompletions: (completions) =>
867
873
  this.ledger.commitToolCompletions(this, completions),
868
- buildModelRequest: (tools) => this.ledger.buildTurnModelRequest(this, tools),
874
+ buildModelRequest: (tools, options) =>
875
+ this.ledger.buildTurnModelRequest(this, tools, options),
869
876
  activateContextSnapshot: (snapshot) =>
870
877
  this.ledger.activateContextSnapshot(snapshot),
871
878
  };
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 (