tinker-agent 1.9.0 → 1.11.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 (52) hide show
  1. package/CHANGELOG.md +36 -1
  2. package/README.md +64 -6
  3. package/package.json +1 -1
  4. package/src/agent/loop.ts +17 -0
  5. package/src/agent/runtime-session.ts +341 -1
  6. package/src/agent/session-ledger.ts +100 -3
  7. package/src/cli/config.ts +11 -2
  8. package/src/cli/model-profiles.ts +58 -0
  9. package/src/cli/public-config-contract.ts +73 -7
  10. package/src/cli/run-runner.ts +4 -1
  11. package/src/cli/runner-dependencies.ts +28 -4
  12. package/src/cli/tui-memory.ts +4 -0
  13. package/src/cli/tui-runner.tsx +8 -1
  14. package/src/context/context-automation-policy.ts +22 -21
  15. package/src/context/context-manager.ts +91 -15
  16. package/src/context/context-policy.ts +0 -2
  17. package/src/context/context-swap-renderer.ts +1 -1
  18. package/src/context/prefix-retirement-planner.ts +58 -8
  19. package/src/context/recall-retirement-contract.ts +5 -4
  20. package/src/context/swap-planner.ts +33 -27
  21. package/src/events/observation-text-log.ts +4 -0
  22. package/src/events/stdout-event-printer.ts +5 -0
  23. package/src/events/types.ts +5 -1
  24. package/src/model/fake-model-client.ts +55 -16
  25. package/src/model/model-api.ts +12 -0
  26. package/src/model/model-client.ts +9 -1
  27. package/src/model/moonshot-input-token-estimator.ts +5 -1
  28. package/src/model/openai-chat-mapping.ts +2 -24
  29. package/src/model/openai-chat-model-client.ts +18 -294
  30. package/src/model/openai-image-mapping.ts +20 -0
  31. package/src/model/openai-model-utils.ts +304 -0
  32. package/src/model/openai-responses-mapping.ts +532 -0
  33. package/src/model/openai-responses-model-client.ts +295 -0
  34. package/src/model/openai-responses-stream.ts +96 -0
  35. package/src/model/openai-responses-token-estimator.ts +155 -0
  36. package/src/model/reasoning-effort.ts +60 -0
  37. package/src/session/session-catalog.ts +2 -2
  38. package/src/session/session-history-reader.ts +6 -1
  39. package/src/session/session-schema.ts +268 -4
  40. package/src/session/session-store.ts +134 -26
  41. package/src/skills/skill-context.ts +2 -2
  42. package/src/tools/bounded-output-preview.ts +276 -0
  43. package/src/tools/recall.ts +67 -36
  44. package/src/tools/registry.ts +7 -2
  45. package/src/tools/task-output-snapshot.ts +6 -22
  46. package/src/tools/task-output.ts +23 -27
  47. package/src/tui/app.tsx +153 -11
  48. package/src/tui/components/footer.tsx +6 -1
  49. package/src/tui/components/prompt-input.tsx +9 -1
  50. package/src/tui/event-store.ts +15 -0
  51. package/src/tui/slash-commands.ts +20 -0
  52. package/src/tui/tui-session-controller.ts +14 -0
@@ -442,7 +442,9 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
442
442
  WHERE role IN ('user', 'assistant', 'tool')
443
443
  AND content IS NOT NULL
444
444
  AND length(content) > 0
445
- AND NOT (role = 'tool' AND name = 'Recall')`,
445
+ AND NOT (
446
+ role = 'tool' AND name IN ('Recall', 'RecallSearch', 'RecallGet')
447
+ )`,
446
448
  },
447
449
  {
448
450
  type: "table",
@@ -462,7 +464,9 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
462
464
  WHEN NEW.role IN ('user', 'assistant', 'tool')
463
465
  AND NEW.content IS NOT NULL
464
466
  AND length(NEW.content) > 0
465
- AND NOT (NEW.role = 'tool' AND NEW.name = 'Recall')
467
+ AND NOT (
468
+ NEW.role = 'tool' AND NEW.name IN ('Recall', 'RecallSearch', 'RecallGet')
469
+ )
466
470
  BEGIN
467
471
  INSERT INTO message_fts(rowid, content)
468
472
  VALUES (NEW.rowid, NEW.content);
@@ -595,7 +599,12 @@ const schemaDefinitions: readonly SchemaDefinition[] = [
595
599
  AND pf.kind = 'user' AND pf.state = 'closed'
596
600
  AND pf.first_ordinal = NEW.keep_from_ordinal
597
601
  AND pf.last_ordinal = NEW.keep_from_ordinal
598
- AND t.status <> 'open'
602
+ AND (
603
+ t.status <> 'open' OR (
604
+ NOT EXISTS (SELECT 1 FROM iterations WHERE outcome = 'open') AND
605
+ NOT EXISTS (SELECT 1 FROM protocol_frames WHERE state = 'open')
606
+ )
607
+ )
599
608
  ) AND
600
609
  EXISTS (
601
610
  SELECT 1 FROM messages m
@@ -850,6 +859,170 @@ export const SESSION_SCHEMA_V9_FINGERPRINT = sha256(
850
859
  }),
851
860
  );
852
861
 
862
+ const PRE_SPLIT_RECALL_SCHEMA_V9_FINGERPRINT =
863
+ "27c61d806778689f88211b6eaa6dd9dc3a730dfd4133c862006140576b2ecd10";
864
+
865
+ const PRE_ACTIVE_TURN_RETIREMENT_SCHEMA_V9_FINGERPRINT =
866
+ "263a0415b343a922efc65aab4a3387a8b31de18e82245ce67b9296b7a02f4a26";
867
+
868
+ const ACTIVE_TURN_RETIREMENT_TRIGGER_FRAGMENT = `AND (
869
+ t.status <> 'open' OR (
870
+ NOT EXISTS (SELECT 1 FROM iterations WHERE outcome = 'open') AND
871
+ NOT EXISTS (SELECT 1 FROM protocol_frames WHERE state = 'open')
872
+ )
873
+ )`;
874
+
875
+ const preActiveTurnRetirementSchemaDefinitions = replaceSchemaDefinitionSql(
876
+ schemaDefinitions,
877
+ "trigger",
878
+ "context_revisions_validate_insert",
879
+ (sql) =>
880
+ replaceExactSqlFragment(
881
+ sql,
882
+ ACTIVE_TURN_RETIREMENT_TRIGGER_FRAGMENT,
883
+ "AND t.status <> 'open'",
884
+ ),
885
+ );
886
+
887
+ const preSplitRecallSchemaDefinitions = replaceSchemaDefinitionSql(
888
+ replaceSchemaDefinitionSql(
889
+ preActiveTurnRetirementSchemaDefinitions,
890
+ "view",
891
+ "recall_documents",
892
+ () => `CREATE VIEW recall_documents AS
893
+ SELECT rowid AS docid, content
894
+ FROM messages
895
+ WHERE role IN ('user', 'assistant', 'tool')
896
+ AND content IS NOT NULL
897
+ AND length(content) > 0
898
+ AND NOT (role = 'tool' AND name = 'Recall')`,
899
+ ),
900
+ "trigger",
901
+ "messages_recall_index",
902
+ () => `CREATE TRIGGER messages_recall_index
903
+ AFTER INSERT ON messages
904
+ WHEN NEW.role IN ('user', 'assistant', 'tool')
905
+ AND NEW.content IS NOT NULL
906
+ AND length(NEW.content) > 0
907
+ AND NOT (NEW.role = 'tool' AND NEW.name = 'Recall')
908
+ BEGIN
909
+ INSERT INTO message_fts(rowid, content)
910
+ VALUES (NEW.rowid, NEW.content);
911
+ END`,
912
+ );
913
+
914
+ export function upgradeRecallIndexContract(database: Database): boolean {
915
+ const applicationId = Number(singlePragmaValue(database, "PRAGMA application_id"));
916
+ const userVersion = Number(singlePragmaValue(database, "PRAGMA user_version"));
917
+ if (
918
+ applicationId !== SESSION_APPLICATION_ID ||
919
+ userVersion !== SESSION_SCHEMA_VERSION
920
+ ) {
921
+ return false;
922
+ }
923
+ const meta = database
924
+ .query(
925
+ `SELECT schema_version, schema_fingerprint
926
+ FROM session_meta WHERE singleton = 1`,
927
+ )
928
+ .get() as { schema_version: unknown; schema_fingerprint: unknown } | null;
929
+ if (
930
+ meta === null ||
931
+ Number(meta.schema_version) !== SESSION_SCHEMA_VERSION ||
932
+ meta.schema_fingerprint !== PRE_SPLIT_RECALL_SCHEMA_V9_FINGERPRINT
933
+ ) {
934
+ return false;
935
+ }
936
+ const recallView = schemaDefinitions.find(
937
+ (definition) =>
938
+ definition.type === "view" && definition.name === "recall_documents",
939
+ );
940
+ const recallTrigger = schemaDefinitions.find(
941
+ (definition) =>
942
+ definition.type === "trigger" && definition.name === "messages_recall_index",
943
+ );
944
+ const metadataTrigger = schemaDefinitions.find(
945
+ (definition) =>
946
+ definition.type === "trigger" &&
947
+ definition.name === "session_meta_monotonic_update",
948
+ );
949
+ if (
950
+ recallView === undefined ||
951
+ recallTrigger === undefined ||
952
+ metadataTrigger === undefined
953
+ ) {
954
+ throw new Error("Recall index schema definitions are missing.");
955
+ }
956
+ database.exec("DROP TRIGGER messages_recall_index");
957
+ database.exec("DROP VIEW recall_documents");
958
+ database.exec(recallView.sql);
959
+ database.exec(recallTrigger.sql);
960
+ rebuildRecallIndex(database);
961
+ database.exec("DROP TRIGGER session_meta_monotonic_update");
962
+ database
963
+ .query(
964
+ `UPDATE session_meta SET schema_fingerprint = ?
965
+ WHERE singleton = 1 AND schema_fingerprint = ?`,
966
+ )
967
+ .run(
968
+ PRE_ACTIVE_TURN_RETIREMENT_SCHEMA_V9_FINGERPRINT,
969
+ PRE_SPLIT_RECALL_SCHEMA_V9_FINGERPRINT,
970
+ );
971
+ database.exec(metadataTrigger.sql);
972
+ return true;
973
+ }
974
+
975
+ export function upgradeActiveTurnRetirementContract(database: Database): boolean {
976
+ const applicationId = Number(singlePragmaValue(database, "PRAGMA application_id"));
977
+ const userVersion = Number(singlePragmaValue(database, "PRAGMA user_version"));
978
+ if (
979
+ applicationId !== SESSION_APPLICATION_ID ||
980
+ userVersion !== SESSION_SCHEMA_VERSION
981
+ ) {
982
+ return false;
983
+ }
984
+ const meta = database
985
+ .query(
986
+ `SELECT schema_version, schema_fingerprint
987
+ FROM session_meta WHERE singleton = 1`,
988
+ )
989
+ .get() as { schema_version: unknown; schema_fingerprint: unknown } | null;
990
+ if (
991
+ meta === null ||
992
+ Number(meta.schema_version) !== SESSION_SCHEMA_VERSION ||
993
+ meta.schema_fingerprint !== PRE_ACTIVE_TURN_RETIREMENT_SCHEMA_V9_FINGERPRINT
994
+ ) {
995
+ return false;
996
+ }
997
+ const revisionTrigger = schemaDefinitions.find(
998
+ (definition) =>
999
+ definition.type === "trigger" &&
1000
+ definition.name === "context_revisions_validate_insert",
1001
+ );
1002
+ const metadataTrigger = schemaDefinitions.find(
1003
+ (definition) =>
1004
+ definition.type === "trigger" &&
1005
+ definition.name === "session_meta_monotonic_update",
1006
+ );
1007
+ if (revisionTrigger === undefined || metadataTrigger === undefined) {
1008
+ throw new Error("Active-turn retirement schema definitions are missing.");
1009
+ }
1010
+ database.exec("DROP TRIGGER context_revisions_validate_insert");
1011
+ database.exec(revisionTrigger.sql);
1012
+ database.exec("DROP TRIGGER session_meta_monotonic_update");
1013
+ database
1014
+ .query(
1015
+ `UPDATE session_meta SET schema_fingerprint = ?
1016
+ WHERE singleton = 1 AND schema_fingerprint = ?`,
1017
+ )
1018
+ .run(
1019
+ SESSION_SCHEMA_V9_FINGERPRINT,
1020
+ PRE_ACTIVE_TURN_RETIREMENT_SCHEMA_V9_FINGERPRINT,
1021
+ );
1022
+ database.exec(metadataTrigger.sql);
1023
+ return true;
1024
+ }
1025
+
853
1026
  export function configureWritableDatabase(database: Database): void {
854
1027
  database.exec("PRAGMA foreign_keys = ON");
855
1028
  const journal = singlePragmaValue(database, "PRAGMA journal_mode = WAL");
@@ -893,7 +1066,61 @@ export function reinstallSessionCloneTriggers(database: Database): void {
893
1066
  }
894
1067
  }
895
1068
 
1069
+ export function verifyReadableSessionSchema(
1070
+ database: Database,
1071
+ sessionId?: SessionId,
1072
+ ): "current" | "migratable" {
1073
+ verifySessionSchemaVersion(database, sessionId);
1074
+ const meta = database
1075
+ .query(
1076
+ `SELECT schema_version, schema_fingerprint
1077
+ FROM session_meta WHERE singleton = 1`,
1078
+ )
1079
+ .get() as { schema_version: unknown; schema_fingerprint: unknown } | null;
1080
+ if (
1081
+ meta === null ||
1082
+ Number(meta.schema_version) !== SESSION_SCHEMA_VERSION ||
1083
+ typeof meta.schema_fingerprint !== "string"
1084
+ ) {
1085
+ throw new SessionError(
1086
+ "SESSION_SCHEMA_INVALID",
1087
+ "verify_readable_schema",
1088
+ "Session schema metadata is invalid.",
1089
+ { sessionId },
1090
+ );
1091
+ }
1092
+
1093
+ let expectedDefinitions: readonly SchemaDefinition[];
1094
+ let compatibility: "current" | "migratable";
1095
+ if (meta.schema_fingerprint === SESSION_SCHEMA_V9_FINGERPRINT) {
1096
+ expectedDefinitions = schemaDefinitions;
1097
+ compatibility = "current";
1098
+ } else if (
1099
+ meta.schema_fingerprint === PRE_ACTIVE_TURN_RETIREMENT_SCHEMA_V9_FINGERPRINT
1100
+ ) {
1101
+ expectedDefinitions = preActiveTurnRetirementSchemaDefinitions;
1102
+ compatibility = "migratable";
1103
+ } else if (meta.schema_fingerprint === PRE_SPLIT_RECALL_SCHEMA_V9_FINGERPRINT) {
1104
+ expectedDefinitions = preSplitRecallSchemaDefinitions;
1105
+ compatibility = "migratable";
1106
+ } else {
1107
+ throw new SessionError(
1108
+ "SESSION_SCHEMA_INVALID",
1109
+ "verify_readable_schema",
1110
+ "Session schema is neither current nor a recognized migration source.",
1111
+ { sessionId },
1112
+ );
1113
+ }
1114
+ verifySessionSchemaDefinitions(database, expectedDefinitions, sessionId);
1115
+ return compatibility;
1116
+ }
1117
+
896
1118
  export function verifySessionSchema(database: Database, sessionId?: SessionId): void {
1119
+ verifySessionSchemaVersion(database, sessionId);
1120
+ verifySessionSchemaDefinitions(database, schemaDefinitions, sessionId);
1121
+ }
1122
+
1123
+ function verifySessionSchemaVersion(database: Database, sessionId?: SessionId): void {
897
1124
  const applicationId = Number(singlePragmaValue(database, "PRAGMA application_id"));
898
1125
  const userVersion = Number(singlePragmaValue(database, "PRAGMA user_version"));
899
1126
  if (
@@ -907,7 +1134,13 @@ export function verifySessionSchema(database: Database, sessionId?: SessionId):
907
1134
  { sessionId },
908
1135
  );
909
1136
  }
1137
+ }
910
1138
 
1139
+ function verifySessionSchemaDefinitions(
1140
+ database: Database,
1141
+ expectedDefinitions: readonly SchemaDefinition[],
1142
+ sessionId?: SessionId,
1143
+ ): void {
911
1144
  const actual = database
912
1145
  .query(
913
1146
  `SELECT type, name, sql FROM sqlite_schema
@@ -918,7 +1151,7 @@ export function verifySessionSchema(database: Database, sessionId?: SessionId):
918
1151
  const actualByName = new Map(
919
1152
  actual.map((entry) => [`${entry.type}:${entry.name}`, normalizeSql(entry.sql)]),
920
1153
  );
921
- for (const expected of schemaDefinitions) {
1154
+ for (const expected of expectedDefinitions) {
922
1155
  const actualSql = actualByName.get(`${expected.type}:${expected.name}`);
923
1156
  if (actualSql !== normalizeSql(expected.sql)) {
924
1157
  throw new SessionError(
@@ -1053,3 +1286,34 @@ function singlePragmaValue(database: Database, sql: string): unknown {
1053
1286
  function normalizeSql(sql: string): string {
1054
1287
  return sql.replace(/\s+/g, " ").trim().replace(/;$/, "").toLowerCase();
1055
1288
  }
1289
+
1290
+ function replaceSchemaDefinitionSql(
1291
+ definitions: readonly SchemaDefinition[],
1292
+ type: SchemaDefinition["type"],
1293
+ name: string,
1294
+ replace: (sql: string) => string,
1295
+ ): readonly SchemaDefinition[] {
1296
+ const target = definitions.find(
1297
+ (definition) => definition.type === type && definition.name === name,
1298
+ );
1299
+ if (target === undefined) {
1300
+ throw new Error(`Schema definition is missing: ${type} ${name}.`);
1301
+ }
1302
+ return definitions.map((definition) =>
1303
+ definition === target
1304
+ ? { ...definition, sql: replace(definition.sql) }
1305
+ : definition,
1306
+ );
1307
+ }
1308
+
1309
+ function replaceExactSqlFragment(
1310
+ sql: string,
1311
+ currentFragment: string,
1312
+ previousFragment: string,
1313
+ ): string {
1314
+ const replaced = sql.replace(currentFragment, previousFragment);
1315
+ if (replaced === sql) {
1316
+ throw new Error("Schema definition does not contain its historical fragment.");
1317
+ }
1318
+ return replaced;
1319
+ }
@@ -28,7 +28,10 @@ import {
28
28
  createModelContextProfile,
29
29
  type ModelContextProfile,
30
30
  } from "../model/model-context-profile";
31
- import type { ModelMessageProtocol } from "../model/model-client";
31
+ import {
32
+ MODEL_MESSAGE_PROTOCOL_ADAPTERS,
33
+ type ModelMessageProtocol,
34
+ } from "../model/model-client";
32
35
  import type { InputTokenEstimatorCompatibility } from "../model/input-token-estimator";
33
36
  import type { ToolDefinition, ToolRawResult } from "../tools/types";
34
37
  import { sha256, stableJsonStringify } from "../model/model-request-preflight";
@@ -45,7 +48,6 @@ import {
45
48
  ContextRevisionCompiler,
46
49
  createInitialContextRevision,
47
50
  } from "../context/context-revision-compiler";
48
- import { recallFirstRetirementPolicyV1 } from "../context/context-policy";
49
51
  import {
50
52
  ContextSwapRenderer,
51
53
  SWAP_OBSERVATION_FORMAT,
@@ -90,7 +92,10 @@ import { ImageAssetStore } from "../image/image-asset-store";
90
92
  import type { MeasuredContextAnchor } from "../agent/context-meter";
91
93
  import type { ProjectInstructionManifest } from "../instructions/project-instructions";
92
94
  import { SUPPORTED_RECALL_RETIREMENT_CONTRACT_VERSIONS } from "../context/recall-retirement-contract";
93
- import type { ClosedTurnBoundary } from "../context/prefix-retirement-planner";
95
+ import type {
96
+ ActiveTurnBoundary,
97
+ ClosedTurnBoundary,
98
+ } from "../context/prefix-retirement-planner";
94
99
  import {
95
100
  AdmissionStaleError,
96
101
  InMemorySessionLedger,
@@ -106,11 +111,14 @@ import { SessionLease } from "./session-lock";
106
111
  import {
107
112
  SESSION_SCHEMA_V9_FINGERPRINT,
108
113
  SESSION_SCHEMA_VERSION,
114
+ upgradeActiveTurnRetirementContract,
115
+ upgradeRecallIndexContract,
109
116
  configureWritableDatabase,
110
117
  createSessionSchema,
111
118
  dropSessionCloneTriggers,
112
119
  rebuildRecallIndex,
113
120
  reinstallSessionCloneTriggers,
121
+ verifyReadableSessionSchema,
114
122
  verifyRecallIndex,
115
123
  verifySessionSchema,
116
124
  verifySqliteIntegrity,
@@ -231,6 +239,7 @@ export type CommitSwapRevisionInput = {
231
239
  nextActiveOverrideManifestSha256: string;
232
240
  canonicalSequenceSha256: string;
233
241
  renderedMessageSha256: string;
242
+ activeTurnId?: TurnId;
234
243
  };
235
244
 
236
245
  export type CommitSwapRevisionFaultStage =
@@ -288,6 +297,7 @@ export type CommitPrefixRetirementRevisionInput = {
288
297
  nextActiveOverrideManifestSha256: string;
289
298
  canonicalSequenceSha256: string;
290
299
  renderedMessageSha256: string;
300
+ activeTurnId?: TurnId;
291
301
  };
292
302
 
293
303
  export type CommitPrefixRetirementRevisionFaultStage =
@@ -564,8 +574,13 @@ export class SessionStore implements SessionLedgerCommitter {
564
574
  let database: Database | undefined;
565
575
  try {
566
576
  database = openWritableDatabase(databasePath);
567
- verifySessionSchema(database, input.sessionId);
568
577
  verifySqliteIntegrity(database, input.sessionId);
578
+ verifyReadableSessionSchema(database, input.sessionId);
579
+ const recallIndexContractUpgraded = runTransaction(database, () =>
580
+ upgradeRecallIndexContract(database!),
581
+ );
582
+ runTransaction(database, () => upgradeActiveTurnRetirementContract(database!));
583
+ verifySessionSchema(database, input.sessionId);
569
584
  const store = new SessionStore(database, lease, {
570
585
  sessionId: input.sessionId,
571
586
  workspaceRoot,
@@ -573,6 +588,7 @@ export class SessionStore implements SessionLedgerCommitter {
573
588
  databasePath,
574
589
  clock,
575
590
  });
591
+ store.recallIndexRebuilt = recallIndexContractUpgraded;
576
592
  const meta = store.readMeta();
577
593
  if (meta.initializationState !== "ready" && input.allowIncomplete !== true) {
578
594
  throw new SessionError(
@@ -644,6 +660,9 @@ export class SessionStore implements SessionLedgerCommitter {
644
660
  case "begin_turn":
645
661
  this.commitBeginTurn(mutation, now);
646
662
  break;
663
+ case "append_steering_users":
664
+ this.commitSteeringUsers(mutation, now);
665
+ break;
647
666
  case "append_assistant":
648
667
  this.commitAssistant(mutation, now);
649
668
  break;
@@ -926,25 +945,38 @@ export class SessionStore implements SessionLedgerCommitter {
926
945
  }
927
946
 
928
947
  assertContextRevisionIdle(): void {
948
+ this.assertContextRevisionBoundary();
949
+ }
950
+
951
+ assertContextRevisionBoundary(activeTurnId?: TurnId): void {
929
952
  this.requireOpen();
930
953
  const row = this.database
931
954
  .query(
932
955
  `SELECT
933
956
  (SELECT COUNT(*) FROM turns WHERE status = 'open') AS open_turns,
957
+ (SELECT turn_id FROM turns WHERE status = 'open' LIMIT 1) AS open_turn_id,
934
958
  (SELECT COUNT(*) FROM iterations WHERE outcome = 'open') AS open_iterations,
935
959
  (SELECT COUNT(*) FROM protocol_frames WHERE state = 'open') AS open_frames`,
936
960
  )
937
961
  .get() as Record<string, unknown> | null;
962
+ const openTurnCount =
963
+ row === null ? -1 : numberFromSql(row.open_turns, "open_turns");
964
+ const openTurnId =
965
+ row === null
966
+ ? null
967
+ : (nullableStringFromSql(row.open_turn_id, "open_turn_id") as TurnId | null);
938
968
  if (
939
969
  row === null ||
940
- numberFromSql(row.open_turns, "open_turns") !== 0 ||
970
+ (activeTurnId === undefined
971
+ ? openTurnCount !== 0
972
+ : openTurnCount !== 1 || openTurnId !== activeTurnId) ||
941
973
  numberFromSql(row.open_iterations, "open_iterations") !== 0 ||
942
974
  numberFromSql(row.open_frames, "open_frames") !== 0
943
975
  ) {
944
976
  throw new SessionError(
945
977
  "SESSION_INTEGRITY_FAILED",
946
- "assert_context_revision_idle",
947
- "Context revision requires a fully idle session store.",
978
+ "assert_context_revision_boundary",
979
+ "Context revision requires an idle store or a closed active-turn iteration boundary.",
948
980
  { sessionId: this.sessionId },
949
981
  );
950
982
  }
@@ -955,16 +987,35 @@ export class SessionStore implements SessionLedgerCommitter {
955
987
  this.assertContextRevisionIdle();
956
988
  const canonical = this.loadProtocolView();
957
989
  this.validator.validate(canonical, { fullIntegrity: true });
958
- return this.readClosedTurnBoundaries(canonical);
990
+ return this.readRetirementBoundaries(canonical).closedTurns;
959
991
  }
960
992
 
961
- private readClosedTurnBoundaries(
993
+ loadRetirementBoundaries(activeTurnId?: TurnId): {
994
+ readonly closedTurns: readonly ClosedTurnBoundary[];
995
+ readonly activeTurn?: ActiveTurnBoundary;
996
+ } {
997
+ this.requireOpen();
998
+ this.assertContextRevisionBoundary(activeTurnId);
999
+ const canonical = this.loadProtocolView();
1000
+ this.validator.validate(canonical, {
1001
+ allowOpenTail: activeTurnId !== undefined,
1002
+ fullIntegrity: true,
1003
+ });
1004
+ return this.readRetirementBoundaries(canonical, activeTurnId);
1005
+ }
1006
+
1007
+ private readRetirementBoundaries(
962
1008
  canonical: ProtocolContextView,
963
- ): readonly ClosedTurnBoundary[] {
1009
+ activeTurnId?: TurnId,
1010
+ ): {
1011
+ readonly closedTurns: readonly ClosedTurnBoundary[];
1012
+ readonly activeTurn?: ActiveTurnBoundary;
1013
+ } {
964
1014
  const rows = this.database
965
1015
  .query("SELECT * FROM turns ORDER BY turn_number")
966
1016
  .all() as Array<Record<string, unknown>>;
967
1017
  const boundaries: ClosedTurnBoundary[] = [];
1018
+ let activeTurn: ActiveTurnBoundary | undefined;
968
1019
  let expectedOrdinal = 2;
969
1020
  for (let index = 0; index < rows.length; index += 1) {
970
1021
  const row = requireItem(rows, index, "turn row");
@@ -972,8 +1023,8 @@ export class SessionStore implements SessionLedgerCommitter {
972
1023
  const turnNumber = numberFromSql(row.turn_number, "turn_number");
973
1024
  const status = enumFromSql(
974
1025
  row.status,
975
- ["completed", "failed", "cancelled", "interrupted"] as const,
976
- "closed turn status",
1026
+ ["open", "completed", "failed", "cancelled", "interrupted"] as const,
1027
+ "turn status",
977
1028
  );
978
1029
  const frames = canonical.frames.filter((frame) => frame.turnId === turnId);
979
1030
  const messages = canonical.messages.filter(
@@ -981,6 +1032,29 @@ export class SessionStore implements SessionLedgerCommitter {
981
1032
  );
982
1033
  const firstMessage = messages[0];
983
1034
  const lastMessage = messages.at(-1);
1035
+ if (status === "open") {
1036
+ if (
1037
+ activeTurnId === undefined ||
1038
+ turnId !== activeTurnId ||
1039
+ index !== rows.length - 1 ||
1040
+ turnNumber !== index + 1 ||
1041
+ messages.length < 1 ||
1042
+ frames.length < 1 ||
1043
+ firstMessage?.role !== "user" ||
1044
+ firstMessage.ordinal !== expectedOrdinal ||
1045
+ lastMessage?.ordinal !== canonical.messages.length ||
1046
+ frames.some((frame) => frame.state !== "closed")
1047
+ ) {
1048
+ throw new Error(`Turn ${turnId} has an invalid active boundary.`);
1049
+ }
1050
+ activeTurn = Object.freeze({
1051
+ turnId,
1052
+ turnNumber,
1053
+ firstOrdinal: expectedOrdinal,
1054
+ });
1055
+ expectedOrdinal = canonical.messages.length + 1;
1056
+ continue;
1057
+ }
984
1058
  let nextFrameOrdinal = expectedOrdinal;
985
1059
  for (const frame of frames) {
986
1060
  if (
@@ -1019,7 +1093,13 @@ export class SessionStore implements SessionLedgerCommitter {
1019
1093
  if (expectedOrdinal !== canonical.messages.length + 1) {
1020
1094
  throw new Error("Closed turn boundaries do not cover canonical history.");
1021
1095
  }
1022
- return Object.freeze(boundaries);
1096
+ if ((activeTurnId === undefined) !== (activeTurn === undefined)) {
1097
+ throw new Error("Active retirement boundary does not match the open turn.");
1098
+ }
1099
+ return Object.freeze({
1100
+ closedTurns: Object.freeze(boundaries),
1101
+ ...(activeTurn === undefined ? {} : { activeTurn }),
1102
+ });
1023
1103
  }
1024
1104
 
1025
1105
  commitSwapRevision(
@@ -1043,7 +1123,7 @@ export class SessionStore implements SessionLedgerCommitter {
1043
1123
  ) {
1044
1124
  throw new Error("Context revision commit base is stale.");
1045
1125
  }
1046
- this.assertContextRevisionIdle();
1126
+ this.assertContextRevisionBoundary(input.activeTurnId);
1047
1127
 
1048
1128
  const active = this.revisionCompiler.compileActive(snapshot);
1049
1129
  const candidateOverrides = [
@@ -1229,25 +1309,27 @@ export class SessionStore implements SessionLedgerCommitter {
1229
1309
  ) {
1230
1310
  throw new Error("Prefix retirement commit base is stale.");
1231
1311
  }
1232
- this.assertContextRevisionIdle();
1233
- const closedTurns = this.readClosedTurnBoundaries(snapshot.canonical);
1312
+ this.assertContextRevisionBoundary(input.activeTurnId);
1313
+ const retirementBoundaries = this.readRetirementBoundaries(
1314
+ snapshot.canonical,
1315
+ input.activeTurnId,
1316
+ );
1317
+ const closedTurns = retirementBoundaries.closedTurns;
1234
1318
  const activeTurns = closedTurns.filter(
1235
1319
  (turn) => turn.firstOrdinal >= baseRevision.keepFromOrdinal,
1236
1320
  );
1237
- const nextBoundary = activeTurns.find(
1238
- (turn) => turn.firstOrdinal === input.nextKeepFromOrdinal,
1239
- );
1240
- const retainedTurns = activeTurns.filter(
1241
- (turn) => turn.firstOrdinal >= input.nextKeepFromOrdinal,
1242
- );
1321
+ const nextBoundary = [
1322
+ ...activeTurns,
1323
+ ...(retirementBoundaries.activeTurn === undefined
1324
+ ? []
1325
+ : [retirementBoundaries.activeTurn]),
1326
+ ].find((turn) => turn.firstOrdinal === input.nextKeepFromOrdinal);
1243
1327
  const retiredTurns = activeTurns.filter(
1244
1328
  (turn) => turn.lastOrdinal < input.nextKeepFromOrdinal,
1245
1329
  );
1246
1330
  if (
1247
1331
  nextBoundary === undefined ||
1248
1332
  input.nextKeepFromOrdinal <= baseRevision.keepFromOrdinal ||
1249
- retainedTurns.length <
1250
- recallFirstRetirementPolicyV1.protectedRecentTurnCount ||
1251
1333
  retiredTurns.length !== input.retiredTurnCount ||
1252
1334
  retiredTurns.reduce((total, turn) => total + turn.frameCount, 0) !==
1253
1335
  input.retiredFrameCount ||
@@ -2769,6 +2851,32 @@ export class SessionStore implements SessionLedgerCommitter {
2769
2851
  requireSingleChange(this.database, updated.changes, "advance turn counter");
2770
2852
  }
2771
2853
 
2854
+ private commitSteeringUsers(
2855
+ mutation: Extract<LedgerMutation, { kind: "append_steering_users" }>,
2856
+ now: string,
2857
+ ): void {
2858
+ const turn = this.requireTurnRow(mutation.turn.turnId);
2859
+ if (turn.status !== "open") {
2860
+ throw new Error(`Turn ${mutation.turn.turnId} is not open.`);
2861
+ }
2862
+ if (
2863
+ mutation.frames.length === 0 ||
2864
+ mutation.frames.length !== mutation.messages.length
2865
+ ) {
2866
+ throw new Error(
2867
+ "Steering user mutation must contain matching frames and messages.",
2868
+ );
2869
+ }
2870
+ for (let index = 0; index < mutation.frames.length; index += 1) {
2871
+ insertFrame(this.database, requireItem(mutation.frames, index, "steering frame"));
2872
+ insertMessage(
2873
+ this.database,
2874
+ requireItem(mutation.messages, index, "steering message"),
2875
+ );
2876
+ }
2877
+ this.touch(now);
2878
+ }
2879
+
2772
2880
  private commitAssistant(
2773
2881
  mutation: Extract<LedgerMutation, { kind: "append_assistant" }>,
2774
2882
  now: string,
@@ -3608,7 +3716,7 @@ export function createSessionCompatibilityContract(input: {
3608
3716
  throw new Error("Session compatibility reasoning replay flag must be boolean.");
3609
3717
  }
3610
3718
  if (
3611
- !["openai-chat", "fake"].includes(input.messageProtocol.adapter) ||
3719
+ !MODEL_MESSAGE_PROTOCOL_ADAPTERS.includes(input.messageProtocol.adapter) ||
3612
3720
  input.messageProtocol.serializationVersion.trim() === ""
3613
3721
  ) {
3614
3722
  throw new Error("Session compatibility message protocol is invalid.");
@@ -5473,7 +5581,7 @@ function decodeSessionCompatibilityContract(
5473
5581
  const protocol: ModelMessageProtocol = Object.freeze({
5474
5582
  adapter: enumFromSql(
5475
5583
  messageProtocol.adapter,
5476
- ["openai-chat", "fake"] as const,
5584
+ MODEL_MESSAGE_PROTOCOL_ADAPTERS,
5477
5585
  "compatibility message adapter",
5478
5586
  ),
5479
5587
  serializationVersion: stringFromSql(
@@ -298,7 +298,7 @@ export function renderSkillActivationReceipt(input: {
298
298
  `source=${source}`,
299
299
  "activation=The full historical instructions were promoted out of this tool observation.",
300
300
  "current=Consult the current active_agent_skills system section; absence means inactive.",
301
- "historical=Use Recall get with source to recover the original activation observation.",
301
+ "historical=Use RecallGet with source to recover the original activation observation.",
302
302
  ].join("\n")
303
303
  : [
304
304
  "[Tinker Agent Skill activation rejected]",
@@ -306,7 +306,7 @@ export function renderSkillActivationReceipt(input: {
306
306
  `source=${source}`,
307
307
  `status=${input.outcome === "unavailable" ? "unavailable" : "not_dispatched"}`,
308
308
  "current=This skill was not added to the active system surface.",
309
- "historical=Use Recall get with source to recover the original activation observation.",
309
+ "historical=Use RecallGet with source to recover the original activation observation.",
310
310
  ].join("\n");
311
311
  const originalBytes = Buffer.byteLength(input.message.content, "utf8");
312
312
  const renderedBytes = Buffer.byteLength(renderedContent, "utf8");