tinker-agent 2.8.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/CHANGELOG.md +79 -1
  2. package/README.md +81 -11
  3. package/package.json +5 -3
  4. package/src/agent/runtime-context-capabilities.ts +19 -0
  5. package/src/agent/runtime-context-events.ts +127 -0
  6. package/src/agent/runtime-context-maintenance.ts +780 -0
  7. package/src/agent/runtime-hosted-session.ts +443 -0
  8. package/src/agent/runtime-interactions.ts +291 -0
  9. package/src/agent/runtime-prompt-scheduler.ts +182 -0
  10. package/src/agent/runtime-session-contracts.ts +317 -0
  11. package/src/agent/runtime-session.ts +250 -2130
  12. package/src/agent/runtime-skills.ts +544 -0
  13. package/src/cli/command-line.ts +26 -2
  14. package/src/cli/connect-runner.tsx +26 -0
  15. package/src/cli/main.ts +26 -0
  16. package/src/cli/output.ts +1 -1
  17. package/src/cli/public-cli-contract.ts +18 -0
  18. package/src/cli/public-config-contract.ts +1 -1
  19. package/src/cli/runner-dependencies.ts +6 -5
  20. package/src/cli/serve-runner.ts +45 -0
  21. package/src/cli/serve-runtime.ts +100 -0
  22. package/src/context/context-automation-policy.ts +12 -118
  23. package/src/context/context-swap-renderer.ts +14 -0
  24. package/src/events/types.ts +12 -0
  25. package/src/memory/memory-get-tool.ts +1 -1
  26. package/src/observation/observation-builder.ts +128 -48
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/resume-projection.ts +47 -21
  36. package/src/session/session-history-access.ts +238 -0
  37. package/src/session/session-store-context-readers.ts +183 -0
  38. package/src/session/session-store-ledger-writer.ts +315 -0
  39. package/src/session/session-store-record-writer.ts +318 -0
  40. package/src/session/session-store-recovery.ts +225 -0
  41. package/src/session/session-store-revisions.ts +1004 -0
  42. package/src/session/session-store-sql.ts +40 -0
  43. package/src/session/session-store-validation.ts +657 -0
  44. package/src/session/session-store.ts +756 -3186
  45. package/src/tools/bash-task.ts +46 -18
  46. package/src/tools/bash.ts +44 -2
  47. package/src/tools/glob.ts +107 -19
  48. package/src/tools/grep-output.ts +130 -0
  49. package/src/tools/grep-pagination.ts +73 -0
  50. package/src/tools/grep-path.ts +11 -0
  51. package/src/tools/grep-snippets.ts +111 -0
  52. package/src/tools/grep.ts +139 -154
  53. package/src/tools/read.ts +0 -9
  54. package/src/tools/recall.ts +106 -50
  55. package/src/tools/registry.ts +4 -6
  56. package/src/tools/ripgrep.ts +19 -26
  57. package/src/tools/shell-process.ts +30 -4
  58. package/src/tools/task-output-range.ts +146 -0
  59. package/src/tools/task-output-tool.ts +35 -5
  60. package/src/tools/task-output.ts +35 -0
  61. package/src/tools/task-stop.ts +2 -1
  62. package/src/tools/task-tool-args.ts +34 -0
  63. package/src/tools/terminal-screen.ts +11 -2
  64. package/src/tools/types.ts +39 -2
  65. package/src/tui/event-store.ts +23 -5
  66. package/src/tui/remote-app.tsx +210 -0
@@ -160,28 +160,25 @@ function projectTurn(
160
160
  .query("SELECT * FROM messages WHERE turn_id = ? ORDER BY ordinal")
161
161
  .all(turnId) as Array<Record<string, unknown>>;
162
162
  const promptRows = messages.filter((message) => message.role === "user");
163
- if (promptRows.length !== 1) {
164
- throw new Error(
165
- `Turn ${turnId} must contain exactly one user message; found ${promptRows.length}.`,
166
- );
167
- }
168
163
  const prompt = promptRows[0];
169
- if (prompt === undefined) {
170
- throw new Error(`Turn ${turnId} user message disappeared.`);
171
- }
172
- const userPrompt = truncateUserPromptProjection(
173
- readUserPromptProjection(database, prompt),
174
- MAX_TIMELINE_PROMPT_CODE_POINTS,
175
- );
176
- const allItems: TimelineItem[] = [
177
- {
178
- id: `resume-${requireString(prompt.message_id, "message_id")}`,
179
- label: "prompt",
180
- text: userPrompt.text,
181
- userPrompt,
182
- status: "text",
183
- },
184
- ];
164
+ if (prompt === undefined || messages[0] !== prompt) {
165
+ throw new Error(`Turn ${turnId} must start with a user message.`);
166
+ }
167
+ const allItems: TimelineItem[] = [projectPrompt(database, prompt, "prompt")];
168
+ let nextPromptIndex = 1;
169
+ const appendFollowUpsBefore = (ordinal: number): void => {
170
+ while (nextPromptIndex < promptRows.length) {
171
+ const followUp = promptRows[nextPromptIndex];
172
+ if (
173
+ followUp === undefined ||
174
+ safeNumber(followUp.ordinal, "user ordinal") >= ordinal
175
+ ) {
176
+ break;
177
+ }
178
+ allItems.push(projectPrompt(database, followUp, "follow-up"));
179
+ nextPromptIndex += 1;
180
+ }
181
+ };
185
182
  const assistantsByIteration = new Map<string, Record<string, unknown>>();
186
183
  for (const message of messages) {
187
184
  const role = requireString(message.role, "message role");
@@ -224,6 +221,15 @@ function projectTurn(
224
221
  .query("SELECT * FROM iterations WHERE turn_id = ? ORDER BY iteration_number")
225
222
  .all(turnId) as Array<Record<string, unknown>>;
226
223
  for (const iteration of iterations) {
224
+ const iterationId = requireString(iteration.iteration_id, "iteration_id");
225
+ const assistant = assistantsByIteration.get(iterationId);
226
+ // Steering is committed between complete tool exchanges. An unanswered
227
+ // terminal iteration has no ordinal, so all remaining follow-ups precede it.
228
+ appendFollowUpsBefore(
229
+ assistant === undefined
230
+ ? Number.POSITIVE_INFINITY
231
+ : safeNumber(assistant.ordinal, "assistant ordinal"),
232
+ );
227
233
  allItems.push(
228
234
  ...projectIteration(
229
235
  iteration,
@@ -234,6 +240,8 @@ function projectTurn(
234
240
  ),
235
241
  );
236
242
  }
243
+ // The iteration limit can end a turn immediately after steering was applied.
244
+ appendFollowUpsBefore(Number.POSITIVE_INFINITY);
237
245
  if (assistantsByIteration.size > 0) {
238
246
  throw new Error(
239
247
  `Turn ${turnId} has assistant messages without matching iterations.`,
@@ -265,6 +273,24 @@ function projectTurn(
265
273
  };
266
274
  }
267
275
 
276
+ function projectPrompt(
277
+ database: Database,
278
+ message: Record<string, unknown>,
279
+ label: "prompt" | "follow-up",
280
+ ): TimelineItem {
281
+ const userPrompt = truncateUserPromptProjection(
282
+ readUserPromptProjection(database, message),
283
+ MAX_TIMELINE_PROMPT_CODE_POINTS,
284
+ );
285
+ return {
286
+ id: `resume-${requireString(message.message_id, "message_id")}`,
287
+ label,
288
+ text: userPrompt.text,
289
+ userPrompt,
290
+ status: "text",
291
+ };
292
+ }
293
+
268
294
  function readUserPromptProjection(
269
295
  database: Database,
270
296
  prompt: Record<string, unknown>,
@@ -0,0 +1,238 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { lstat, readdir, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
5
+ import { parseSessionId, type SessionId } from "../ids/runtime-id";
6
+ import { SessionError } from "./session-errors";
7
+ import {
8
+ createSessionHistoryReader,
9
+ RecallHistoryError,
10
+ type SessionHistoryReader,
11
+ } from "./session-history-reader";
12
+ import { verifyReadableSessionSchema } from "./session-schema";
13
+ import {
14
+ safeSessionDirectory,
15
+ validateSecureDirectory,
16
+ validateSecureFile,
17
+ validateSecureOptionalFile,
18
+ validateSessionsRoot,
19
+ } from "./session-store-filesystem";
20
+ import { canonicalHomeRoot, workspaceStorageRoot } from "./workspace-storage";
21
+
22
+ export type RecallSessionErrorCode =
23
+ | "RECALL_SESSION_NOT_FOUND"
24
+ | "RECALL_SESSION_AMBIGUOUS"
25
+ | "RECALL_SESSION_UNSUPPORTED"
26
+ | "RECALL_SESSION_UNAVAILABLE";
27
+
28
+ export class RecallSessionError extends Error {
29
+ constructor(
30
+ readonly code: RecallSessionErrorCode,
31
+ message: string,
32
+ options?: ErrorOptions,
33
+ ) {
34
+ super(message, options);
35
+ this.name = "RecallSessionError";
36
+ }
37
+ }
38
+
39
+ export interface SessionHistoryAccess {
40
+ readonly currentSessionId: SessionId;
41
+ withHistoryReader<T>(
42
+ sessionId: SessionId | undefined,
43
+ signal: AbortSignal,
44
+ read: (reader: SessionHistoryReader, workspaceRoot: string) => T,
45
+ ): Promise<T>;
46
+ }
47
+
48
+ /** External history never goes through execution locks, recovery or migrations. */
49
+ export function createSessionHistoryAccess(input: {
50
+ historyReader: SessionHistoryReader;
51
+ workspaceRoot: string;
52
+ homeRoot?: string;
53
+ }): SessionHistoryAccess {
54
+ return {
55
+ currentSessionId: input.historyReader.sessionId,
56
+ async withHistoryReader(sessionId, signal, read) {
57
+ throwIfTurnCancelled(signal);
58
+ if (sessionId === undefined || sessionId === input.historyReader.sessionId) {
59
+ const result = read(input.historyReader, input.workspaceRoot);
60
+ throwIfTurnCancelled(signal);
61
+ return result;
62
+ }
63
+ // Defence in depth: this session API also rejects non-canonical IDs.
64
+ parseSessionId(sessionId);
65
+ try {
66
+ const location = await locateHistorySession({ ...input, sessionId, signal });
67
+ throwIfTurnCancelled(signal);
68
+ await validateHistoryFiles(location.databasePath, sessionId);
69
+ throwIfTurnCancelled(signal);
70
+ const database = new Database(location.databasePath, {
71
+ readonly: true,
72
+ strict: true,
73
+ safeIntegers: true,
74
+ });
75
+ try {
76
+ // Connection-local only. Never change journal mode or ignore a live WAL.
77
+ database.exec("PRAGMA busy_timeout = 250");
78
+ const result = database.transaction(() => {
79
+ throwIfTurnCancelled(signal);
80
+ if (verifyReadableSessionSchema(database, sessionId) !== "current") {
81
+ throw new RecallSessionError(
82
+ "RECALL_SESSION_UNSUPPORTED",
83
+ "Selected session requires migration; Recall does not upgrade history.",
84
+ );
85
+ }
86
+ const workspaceRoot = validateHistoryIdentity(
87
+ database,
88
+ location,
89
+ sessionId,
90
+ );
91
+ const reader = createSessionHistoryReader({
92
+ database,
93
+ sessionId,
94
+ requireOpen: () => throwIfTurnCancelled(signal),
95
+ });
96
+ const value = read(reader, workspaceRoot);
97
+ throwIfTurnCancelled(signal);
98
+ return value;
99
+ })();
100
+ throwIfTurnCancelled(signal);
101
+ return result;
102
+ } finally {
103
+ database.close(true);
104
+ }
105
+ } catch (error) {
106
+ throwIfTurnCancelled(signal);
107
+ if (
108
+ error instanceof RecallSessionError ||
109
+ error instanceof RecallHistoryError
110
+ ) {
111
+ throw error;
112
+ }
113
+ const code =
114
+ error instanceof SessionError && error.code === "SESSION_SCHEMA_UNSUPPORTED"
115
+ ? "RECALL_SESSION_UNSUPPORTED"
116
+ : "RECALL_SESSION_UNAVAILABLE";
117
+ const reason = error instanceof Error ? error.message : "Unknown read failure";
118
+ throw new RecallSessionError(
119
+ code,
120
+ `Selected session ${sessionId}: ${reason.slice(0, 400)}`,
121
+ { cause: error },
122
+ );
123
+ }
124
+ },
125
+ };
126
+ }
127
+
128
+ type HistoryLocation = { databasePath: string; homeRoot: string; projectRoot: string };
129
+
130
+ async function locateHistorySession(input: {
131
+ workspaceRoot: string;
132
+ homeRoot?: string;
133
+ sessionId: SessionId;
134
+ signal: AbortSignal;
135
+ }): Promise<HistoryLocation> {
136
+ const homeRoot = await canonicalHomeRoot(input.homeRoot);
137
+ const currentProject = workspaceStorageRoot(input.workspaceRoot, homeRoot);
138
+ const projectsRoot = path.dirname(currentProject);
139
+ // Do not follow aliases in the storage hierarchy or silently skip failed scans.
140
+ for (const directory of [path.dirname(projectsRoot), projectsRoot]) {
141
+ if (!(await exists(directory))) {
142
+ throw new RecallSessionError(
143
+ "RECALL_SESSION_NOT_FOUND",
144
+ "Selected session does not exist.",
145
+ );
146
+ }
147
+ const stats = await lstat(directory);
148
+ if (!stats.isDirectory() || (await realpath(directory)) !== directory) {
149
+ throw new Error("History storage root must be a canonical real directory.");
150
+ }
151
+ }
152
+ const projects = await readdir(projectsRoot, { withFileTypes: true });
153
+ const roots = new Set([currentProject]);
154
+ for (const project of projects) {
155
+ throwIfTurnCancelled(input.signal);
156
+ if (project.isSymbolicLink()) {
157
+ throw new Error(
158
+ "Cannot complete history lookup through a symlinked project directory.",
159
+ );
160
+ }
161
+ if (project.isDirectory()) roots.add(path.join(projectsRoot, project.name));
162
+ }
163
+ const candidates: HistoryLocation[] = [];
164
+ for (const projectRoot of roots) {
165
+ throwIfTurnCancelled(input.signal);
166
+ const sessionsRoot = path.join(projectRoot, "sessions");
167
+ if (!(await exists(sessionsRoot))) continue;
168
+ await validateSessionsRoot(sessionsRoot, input.sessionId);
169
+ const directory = safeSessionDirectory(sessionsRoot, input.sessionId);
170
+ if (await exists(directory)) {
171
+ candidates.push({
172
+ databasePath: path.join(directory, "session.sqlite"),
173
+ homeRoot,
174
+ projectRoot,
175
+ });
176
+ }
177
+ }
178
+ if (candidates.length === 0) {
179
+ throw new RecallSessionError(
180
+ "RECALL_SESSION_NOT_FOUND",
181
+ "Selected session does not exist.",
182
+ );
183
+ }
184
+ if (candidates.length !== 1) {
185
+ throw new RecallSessionError(
186
+ "RECALL_SESSION_AMBIGUOUS",
187
+ "Selected session ID occurs in multiple project directories.",
188
+ );
189
+ }
190
+ return candidates[0];
191
+ }
192
+
193
+ async function exists(filePath: string): Promise<boolean> {
194
+ try {
195
+ await lstat(filePath);
196
+ return true;
197
+ } catch (error) {
198
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ async function validateHistoryFiles(databasePath: string, sessionId: SessionId) {
204
+ const directory = path.dirname(databasePath);
205
+ await validateSecureDirectory(path.dirname(path.dirname(directory)), sessionId);
206
+ await validateSecureDirectory(path.dirname(directory), sessionId);
207
+ await validateSecureDirectory(directory, sessionId);
208
+ await validateSecureFile(databasePath, sessionId);
209
+ await validateSecureOptionalFile(`${databasePath}-wal`, sessionId);
210
+ await validateSecureOptionalFile(`${databasePath}-shm`, sessionId);
211
+ }
212
+
213
+ function validateHistoryIdentity(
214
+ database: Database,
215
+ location: HistoryLocation,
216
+ sessionId: SessionId,
217
+ ): string {
218
+ const rows = database
219
+ .query("SELECT session_id, workspace_root, initialization_state FROM session_meta")
220
+ .all() as Array<Record<string, unknown>>;
221
+ const meta = rows[0];
222
+ if (
223
+ rows.length !== 1 ||
224
+ meta?.session_id !== sessionId ||
225
+ meta.initialization_state !== "ready" ||
226
+ typeof meta.workspace_root !== "string" ||
227
+ !path.isAbsolute(meta.workspace_root) ||
228
+ path.normalize(meta.workspace_root) !== meta.workspace_root ||
229
+ workspaceStorageRoot(meta.workspace_root, location.homeRoot) !==
230
+ location.projectRoot
231
+ ) {
232
+ throw new Error(
233
+ "Selected session identity, workspace storage path or initialization state is invalid.",
234
+ );
235
+ }
236
+ // The original workspace may no longer exist. Its stored canonical path is enough.
237
+ return meta.workspace_root;
238
+ }
@@ -0,0 +1,183 @@
1
+ import type { Database } from "bun:sqlite";
2
+ import type { StoredContextRevisionV8 } from "../context/context-revision";
3
+ import type {
4
+ ActiveTurnBoundary,
5
+ ClosedTurnBoundary,
6
+ } from "../context/prefix-retirement-planner";
7
+ import { type ProtocolContextView } from "../context/protocol-frame";
8
+ import type { ContextRevisionId, SessionId, TurnId } from "../ids/runtime-id";
9
+ import {
10
+ type StoredMeasuredContextState,
11
+ type StoredSessionMetaV10,
12
+ } from "./session-store-contracts";
13
+ import { requireItem } from "./session-store-sql";
14
+ import {
15
+ enumFromSql,
16
+ numberFromSql,
17
+ recordFromSql,
18
+ sha256FromSql,
19
+ stringFromSql,
20
+ timestampFromSql,
21
+ } from "./session-store-value-codecs";
22
+
23
+ export function requireActiveRevisionId(meta: StoredSessionMetaV10): ContextRevisionId {
24
+ if (meta.initializationState !== "ready" || meta.activeRevisionId === null) {
25
+ throw new Error("Session has no active context revision.");
26
+ }
27
+ return meta.activeRevisionId;
28
+ }
29
+
30
+ export function previousRevision(
31
+ revisions: readonly StoredContextRevisionV8[],
32
+ revision: StoredContextRevisionV8,
33
+ ): StoredContextRevisionV8 | undefined {
34
+ return revisions[revision.revisionNumber - 2];
35
+ }
36
+
37
+ function decodeMeasuredContextState(
38
+ value: unknown,
39
+ expectedSessionId: SessionId,
40
+ ): StoredMeasuredContextState {
41
+ const row = recordFromSql(value, "context measurement state");
42
+ const sessionId = stringFromSql(row.session_id, "session_id") as SessionId;
43
+ if (sessionId !== expectedSessionId) {
44
+ throw new Error(
45
+ `Context measurement session ID ${sessionId} does not match store.`,
46
+ );
47
+ }
48
+ const promptTokens = numberFromSql(row.prompt_tokens, "prompt_tokens");
49
+ const completionTokens = numberFromSql(row.completion_tokens, "completion_tokens");
50
+ const totalTokens = numberFromSql(row.total_tokens, "total_tokens");
51
+ if (totalTokens !== promptTokens + completionTokens) {
52
+ throw new Error(
53
+ "Context measurement total_tokens must equal prompt_tokens + completion_tokens.",
54
+ );
55
+ }
56
+
57
+ timestampFromSql(row.updated_at, "updated_at");
58
+ return Object.freeze({
59
+ revisionId: stringFromSql(row.revision_id, "revision_id") as ContextRevisionId,
60
+ anchor: Object.freeze({
61
+ totalTokens,
62
+ promptTokens,
63
+ completionTokens,
64
+ segmentCount: numberFromSql(row.segment_count, "segment_count"),
65
+ prefixHash: sha256FromSql(row.prefix_hash, "prefix_hash"),
66
+ requestConfigHash: sha256FromSql(row.request_config_hash, "request_config_hash"),
67
+ toolSchemaHash: sha256FromSql(row.tool_schema_hash, "tool_schema_hash"),
68
+ }),
69
+ });
70
+ }
71
+
72
+ export function readRetirementBoundaries(
73
+ database: Database,
74
+ canonical: ProtocolContextView,
75
+ activeTurnId?: TurnId,
76
+ ): {
77
+ readonly closedTurns: readonly ClosedTurnBoundary[];
78
+ readonly activeTurn?: ActiveTurnBoundary;
79
+ } {
80
+ const rows = database
81
+ .query("SELECT * FROM turns ORDER BY turn_number")
82
+ .all() as Array<Record<string, unknown>>;
83
+ const boundaries: ClosedTurnBoundary[] = [];
84
+ let activeTurn: ActiveTurnBoundary | undefined;
85
+ let expectedOrdinal = 2;
86
+ for (let index = 0; index < rows.length; index += 1) {
87
+ const row = requireItem(rows, index, "turn row");
88
+ const turnId = stringFromSql(row.turn_id, "turn_id") as TurnId;
89
+ const turnNumber = numberFromSql(row.turn_number, "turn_number");
90
+ const status = enumFromSql(
91
+ row.status,
92
+ ["open", "completed", "failed", "cancelled", "interrupted"] as const,
93
+ "turn status",
94
+ );
95
+ const frames = canonical.frames.filter((frame) => frame.turnId === turnId);
96
+ const messages = canonical.messages.filter(
97
+ (message) => message.role !== "system" && message.turnId === turnId,
98
+ );
99
+ const firstMessage = messages[0];
100
+ const lastMessage = messages.at(-1);
101
+ if (status === "open") {
102
+ if (
103
+ activeTurnId === undefined ||
104
+ turnId !== activeTurnId ||
105
+ index !== rows.length - 1 ||
106
+ turnNumber !== index + 1 ||
107
+ messages.length < 1 ||
108
+ frames.length < 1 ||
109
+ firstMessage?.role !== "user" ||
110
+ firstMessage.ordinal !== expectedOrdinal ||
111
+ lastMessage?.ordinal !== canonical.messages.length ||
112
+ frames.some((frame) => frame.state !== "closed")
113
+ ) {
114
+ throw new Error(`Turn ${turnId} has an invalid active boundary.`);
115
+ }
116
+ activeTurn = Object.freeze({
117
+ turnId,
118
+ turnNumber,
119
+ firstOrdinal: expectedOrdinal,
120
+ });
121
+ expectedOrdinal = canonical.messages.length + 1;
122
+ continue;
123
+ }
124
+ let nextFrameOrdinal = expectedOrdinal;
125
+ for (const frame of frames) {
126
+ if (
127
+ frame.state !== "closed" ||
128
+ frame.firstOrdinal !== nextFrameOrdinal ||
129
+ frame.lastOrdinal === undefined
130
+ ) {
131
+ throw new Error(`Turn ${turnId} has an invalid closed frame boundary.`);
132
+ }
133
+ nextFrameOrdinal = frame.lastOrdinal + 1;
134
+ }
135
+ if (
136
+ turnNumber !== index + 1 ||
137
+ frames.length < 1 ||
138
+ messages.length < 1 ||
139
+ firstMessage?.role !== "user" ||
140
+ firstMessage.ordinal !== expectedOrdinal ||
141
+ lastMessage === undefined ||
142
+ nextFrameOrdinal !== lastMessage.ordinal + 1
143
+ ) {
144
+ throw new Error(`Turn ${turnId} has an invalid canonical boundary.`);
145
+ }
146
+ boundaries.push(
147
+ Object.freeze({
148
+ turnId,
149
+ turnNumber,
150
+ status,
151
+ firstOrdinal: expectedOrdinal,
152
+ lastOrdinal: lastMessage.ordinal,
153
+ frameCount: frames.length,
154
+ messageCount: messages.length,
155
+ }),
156
+ );
157
+ expectedOrdinal = lastMessage.ordinal + 1;
158
+ }
159
+ if (expectedOrdinal !== canonical.messages.length + 1) {
160
+ throw new Error("Closed turn boundaries do not cover canonical history.");
161
+ }
162
+ if ((activeTurnId === undefined) !== (activeTurn === undefined)) {
163
+ throw new Error("Active retirement boundary does not match the open turn.");
164
+ }
165
+ return Object.freeze({
166
+ closedTurns: Object.freeze(boundaries),
167
+ ...(activeTurn === undefined ? {} : { activeTurn }),
168
+ });
169
+ }
170
+
171
+ export function loadMeasuredContextState(
172
+ database: Database,
173
+ sessionId: SessionId,
174
+ ): StoredMeasuredContextState | undefined {
175
+ const rows = database.query("SELECT * FROM context_measurement_state").all();
176
+ if (rows.length > 1) {
177
+ throw new Error(
178
+ `Expected at most one context measurement row; found ${rows.length}.`,
179
+ );
180
+ }
181
+ const row = rows[0];
182
+ return row === undefined ? undefined : decodeMeasuredContextState(row, sessionId);
183
+ }