gentle-pi 4.1.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (249) hide show
  1. package/README.md +352 -128
  2. package/assets/agents/gentle-ai-explore.md +1 -0
  3. package/assets/agents/gentle-ai-verify.md +6 -4
  4. package/assets/agents/gentle-ai-worker.md +7 -1
  5. package/assets/orchestrator-delegation-reference.md +163 -0
  6. package/assets/orchestrator-delegation.md +25 -176
  7. package/assets/orchestrator-tracking.md +1 -1
  8. package/assets/orchestrator-verification.md +14 -8
  9. package/assets/orchestrator-writer.md +5 -1
  10. package/assets/orchestrator.md +9 -9
  11. package/bin/engram-guard +1 -1
  12. package/bin/gentle-shell.mjs +188 -35
  13. package/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
  14. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/README.md +7 -0
  15. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/manifest.json +11 -2
  16. package/contracts/review-provider-contract-mirror/v1.4.0/bundle/routing/pi.md +92 -0
  17. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-capabilities.baseline.json +8 -1
  18. package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/generated/provider-roles.baseline.json +1 -1
  19. package/docs/assets/diagrams/agent-orchestration.svg +54 -15
  20. package/docs/assets/diagrams/gentleman-workflow.svg +69 -11
  21. package/docs/assets/diagrams/measured-routing.svg +49 -0
  22. package/docs/assets/diagrams/native-review.svg +52 -12
  23. package/docs/assets/diagrams/odd-workflow.svg +128 -66
  24. package/docs/assets/diagrams/permission-modes.svg +51 -0
  25. package/docs/assets/features/audio-notifications.png +0 -0
  26. package/docs/assets/features/command-palette.png +0 -0
  27. package/docs/auto-permission-answers.md +25 -0
  28. package/docs/auto-permission-evidence.md +39 -0
  29. package/docs/bundled-install.md +40 -8
  30. package/docs/delegated-verification.md +10 -8
  31. package/docs/gentle-agents-activity.md +11 -5
  32. package/docs/gentle-shell-uninstall.md +8 -1
  33. package/docs/gentle-shell.md +20 -12
  34. package/docs/install-wizard.md +17 -10
  35. package/docs/permission-modes.md +51 -6
  36. package/docs/readme-reference.md +155 -31
  37. package/docs/session-profile-format.md +39 -0
  38. package/docs/yolo-mode.md +21 -7
  39. package/extensions/ask-user-choice.ts +4 -0
  40. package/extensions/ask-user-question.ts +6 -1
  41. package/extensions/codegraph-tools.ts +68 -6
  42. package/extensions/gentle-agents.ts +146 -28
  43. package/extensions/gentle-ai.ts +380 -68
  44. package/extensions/gentle-herdr-activity.ts +2 -3
  45. package/extensions/gentle-shell.ts +53 -43
  46. package/extensions/gentle-todo.ts +62 -18
  47. package/extensions/quiet-tools.ts +1 -1
  48. package/extensions/session-scratch.ts +365 -0
  49. package/extensions/startup-banner.ts +2 -3
  50. package/lib/agent-home.ts +8 -3
  51. package/lib/agents-protocol.ts +3 -0
  52. package/lib/agents-runner.ts +55 -6
  53. package/lib/agents-view.ts +3 -1
  54. package/lib/auto-permission-answers.ts +133 -0
  55. package/lib/auto-permission-classifier.ts +215 -36
  56. package/lib/auto-permission-evidence.ts +88 -0
  57. package/lib/auto-permission-memory.ts +69 -0
  58. package/lib/auto-permission-runtime.ts +251 -35
  59. package/lib/auto-permission-tool-context.ts +61 -0
  60. package/lib/bounded-writer-admission.ts +51 -0
  61. package/lib/command-palette-catalog.ts +0 -4
  62. package/lib/commit-slice-signal.ts +100 -0
  63. package/lib/context-backstop.ts +84 -0
  64. package/lib/delivered-test-lint.ts +80 -0
  65. package/lib/destructive-command-guard.ts +215 -28
  66. package/lib/gauge-theme.ts +146 -0
  67. package/lib/gentle-ai-binary.ts +28 -7
  68. package/lib/gentle-shell-bundled.ts +62 -8
  69. package/lib/gentle-shell-import-pi.ts +233 -0
  70. package/lib/gentle-shell-launcher.ts +146 -12
  71. package/lib/gentle-shell-uninstall.ts +18 -1
  72. package/lib/herdr-activity.ts +24 -0
  73. package/lib/model-routing-authority.ts +6 -1
  74. package/lib/native-review-cli.ts +9 -1
  75. package/lib/orchestrator-discovery.ts +5 -1
  76. package/lib/orchestrator-presence.ts +9 -0
  77. package/lib/orchestrator-scope.ts +21 -3
  78. package/lib/provider-contract-bundle.ts +99 -53
  79. package/lib/review-candidate-view-owner.ts +29 -19
  80. package/lib/review-candidate-view.ts +36 -2
  81. package/lib/review-risk-assessment.ts +43 -38
  82. package/lib/review-session-standing-permission.ts +3 -1
  83. package/lib/selection-engine.ts +4 -1
  84. package/lib/session-profile-freeze.ts +40 -16
  85. package/lib/session-profile-persistence.ts +187 -0
  86. package/lib/session-scratch.ts +474 -0
  87. package/lib/shell-bar.ts +25 -25
  88. package/lib/shell-changes-view.ts +80 -13
  89. package/lib/shell-changes.ts +2 -0
  90. package/lib/shell-gauge.ts +49 -6
  91. package/lib/shell-prompt.ts +6 -4
  92. package/lib/shell-todo.ts +39 -8
  93. package/lib/shell-usage-view.ts +5 -1
  94. package/lib/shell-usage.ts +7 -7
  95. package/lib/verifier-admission.ts +29 -0
  96. package/lib/vim-editor-adapter.ts +22 -4
  97. package/lib/yolo-temp-scratch.ts +69 -0
  98. package/package.json +5 -6
  99. package/runtime/gentle-ai-binary.mjs +28 -7
  100. package/runtime/gentle-shell-bundled.mjs +61 -7
  101. package/runtime/gentle-shell-import-pi.mjs +234 -0
  102. package/runtime/gentle-shell-launcher.mjs +144 -10
  103. package/runtime/gentle-shell-uninstall.mjs +18 -1
  104. package/runtime/native-review-cli.mjs +8 -0
  105. package/runtime/review-risk-assessment.mjs +43 -38
  106. package/scripts/build-runtime-modules.mjs +1 -0
  107. package/scripts/bundled-engram-migration.mjs +125 -0
  108. package/scripts/gentle-ai-installer.mjs +10 -10
  109. package/scripts/install-wizard-preview.mjs +2 -2
  110. package/scripts/installer-downloads.mjs +7 -7
  111. package/scripts/installer-preflight.mjs +1 -1
  112. package/scripts/installer-server.mjs +14 -4
  113. package/scripts/main-channel.mjs +3 -2
  114. package/scripts/pi-update.mjs +223 -0
  115. package/scripts/run-test-suite.mjs +5 -0
  116. package/scripts/verify-package-files.mjs +19 -13
  117. package/skills/gentle-ai/SKILL.md +3 -3
  118. package/skills/work-unit-commits/SKILL.md +5 -3
  119. package/tests/agent-risk-escalation.test.ts +37 -10
  120. package/tests/agents-runner.test.ts +260 -3
  121. package/tests/append-system-prompt-route.test.ts +18 -1
  122. package/tests/auto-mode-human-answers.test.ts +68 -0
  123. package/tests/auto-mode-invariants.test.ts +23 -5
  124. package/tests/auto-mode-sdk.test.ts +29 -2
  125. package/tests/auto-permission-answers.test.ts +170 -0
  126. package/tests/auto-permission-classifier.test.ts +353 -2
  127. package/tests/auto-permission-compound.test.ts +24 -0
  128. package/tests/auto-permission-evidence.test.ts +88 -0
  129. package/tests/auto-permission-memory.test.ts +46 -0
  130. package/tests/auto-permission-public-regressions.test.ts +111 -0
  131. package/tests/auto-permission-runtime.test.ts +270 -12
  132. package/tests/autonomous-guard.test.ts +208 -16
  133. package/tests/bounded-writer-admission.test.ts +11 -3
  134. package/tests/bundled-install.test.ts +3 -3
  135. package/tests/card-content-policy.test.ts +19 -9
  136. package/tests/child-safety.test.ts +39 -0
  137. package/tests/codegraph-tools.test.ts +123 -2
  138. package/tests/command-palette.test.ts +14 -4
  139. package/tests/commit-slice-signal.test.ts +162 -0
  140. package/tests/context-backstop.test.ts +159 -0
  141. package/tests/delegate-for-reason-contract.test.ts +91 -24
  142. package/tests/delivered-test-lint.test.ts +105 -0
  143. package/tests/destructive-command-guard.test.ts +85 -1
  144. package/tests/engram-safety.test.ts +35 -7
  145. package/tests/fixtures/primary-session-prompt/gentleman.golden.md +143 -0
  146. package/tests/fixtures/provider-contract-bundle/v1.4.0/README.md +29 -0
  147. package/tests/fixtures/provider-contract-bundle/v1.4.0/manifest.json +83 -0
  148. package/tests/fixtures/provider-contract-bundle/v1.4.0/orchestration/pi.md +70 -0
  149. package/tests/fixtures/provider-contract-bundle/v1.4.0/routing/pi.md +92 -0
  150. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/lens.schema.json +16 -0
  151. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/refuter.schema.json +1 -0
  152. package/tests/fixtures/provider-contract-bundle/v1.4.0/schemas/targeted-validator.schema.json +1 -0
  153. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/lens.json +1 -0
  154. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/refuter.json +1 -0
  155. package/tests/fixtures/provider-contract-bundle/v1.4.0/vectors/targeted-validator.json +1 -0
  156. package/tests/gauge-theme.test.ts +120 -0
  157. package/tests/gentle-agents.test.ts +443 -8
  158. package/tests/gentle-ai-binary.test.ts +1 -1
  159. package/tests/gentle-ai-child-guards.test.ts +61 -0
  160. package/tests/gentle-ai-doctor-herdr.test.ts +94 -0
  161. package/tests/gentle-ai-installer.test.ts +51 -51
  162. package/tests/gentle-shell-bin.test.ts +180 -17
  163. package/tests/gentle-shell-bundled.test.ts +235 -38
  164. package/tests/gentle-shell-import-pi.test.ts +262 -0
  165. package/tests/gentle-shell-launcher.test.ts +97 -13
  166. package/tests/gentle-shell-uninstall.test.ts +30 -0
  167. package/tests/gentle-shell.test.ts +113 -14
  168. package/tests/gentle-todo.test.ts +258 -27
  169. package/tests/headless-delivery-question.test.ts +58 -0
  170. package/tests/herdr-activity.test.ts +34 -1
  171. package/tests/install-wizard.test.ts +2 -2
  172. package/tests/installer-posix-bootstrap.test.ts +8 -8
  173. package/tests/installer-preflight.test.ts +3 -3
  174. package/tests/installer-probes.test.ts +8 -8
  175. package/tests/installer-runner.test.ts +14 -13
  176. package/tests/installer-server.test.ts +13 -5
  177. package/tests/installer-windows-bootstrap.test.ts +33 -4
  178. package/tests/main-channel-upgrade.test.ts +7 -1
  179. package/tests/native-review-capability-contract.test.ts +7 -1
  180. package/tests/native-review-parity.test.ts +22 -1
  181. package/tests/no-self-pause-contract.test.ts +5 -2
  182. package/tests/odd-integration.test.ts +5 -1
  183. package/tests/odd-routing-canonical-ratchet.test.ts +127 -162
  184. package/tests/odd-routing-contract.test.ts +115 -30
  185. package/tests/odd-runtime-delegation-gate.test.ts +4 -1
  186. package/tests/orchestrator-budget.test.ts +19 -8
  187. package/tests/orchestrator-discovery.test.ts +29 -0
  188. package/tests/orchestrator-rdd-ownership.test.ts +3 -1
  189. package/tests/orchestrator-scope.test.ts +66 -0
  190. package/tests/orchestrator-status-publication-contract.test.ts +12 -8
  191. package/tests/package-manifest.test.ts +28 -24
  192. package/tests/pi-update-launcher.test.ts +106 -0
  193. package/tests/pi-update.test.ts +186 -0
  194. package/tests/primary-session-prompt.test.ts +101 -0
  195. package/tests/provider-contract-bundle.test.ts +118 -0
  196. package/tests/provider-contract-mirror.test.ts +24 -0
  197. package/tests/quiet-tool-rendering.test.ts +23 -0
  198. package/tests/rdd-aware-verification-contract.test.ts +29 -11
  199. package/tests/rdd-status-line.test.ts +131 -0
  200. package/tests/review-agent-end-preflight.test.ts +6 -0
  201. package/tests/review-candidate-view.test.ts +49 -0
  202. package/tests/review-contract-prompt.test.ts +5 -2
  203. package/tests/review-controller-native-routing.test.ts +14 -1
  204. package/tests/review-controller.test.ts +1 -1
  205. package/tests/review-host-relay-routing.test.ts +59 -2
  206. package/tests/review-loadout-rdd-off-sdk.test.ts +237 -0
  207. package/tests/review-owner-identity.test.ts +132 -0
  208. package/tests/review-risk-assessment.test.ts +163 -76
  209. package/tests/review-session-standing-permission-controller.test.ts +14 -1
  210. package/tests/review-session-standing-permission.test.ts +27 -1
  211. package/tests/run-test-suite.test.ts +15 -0
  212. package/tests/runtime-harness.mjs +16 -6
  213. package/tests/selection-engine.test.ts +35 -5
  214. package/tests/session-changes-shell.test.ts +1 -1
  215. package/tests/session-profile-freeze.test.ts +37 -1
  216. package/tests/session-profile-persistence.test.ts +334 -0
  217. package/tests/session-scratch.test.ts +945 -0
  218. package/tests/session-subject-routing-contract.test.ts +2 -1
  219. package/tests/shell-bar.test.ts +7 -0
  220. package/tests/shell-changes-view.test.ts +137 -1
  221. package/tests/shell-changes.test.ts +18 -0
  222. package/tests/shell-gauge.test.ts +42 -0
  223. package/tests/shell-todo.test.ts +15 -12
  224. package/tests/small-task-inline-contract.test.ts +37 -0
  225. package/tests/standalone-config-home.test.ts +324 -0
  226. package/tests/startup-banner.test.ts +5 -4
  227. package/tests/startup-home-repo.test.ts +34 -0
  228. package/tests/support/orchestrator-modules.ts +1 -0
  229. package/tests/task-size-routing-contract.test.ts +69 -9
  230. package/tests/test-scratch-contract.test.ts +102 -0
  231. package/tests/test-strength-contract.test.ts +36 -1
  232. package/tests/tool-batching-contract.test.ts +64 -0
  233. package/tests/verification-proportionality.test.ts +46 -0
  234. package/tests/verifier-admission.test.ts +137 -0
  235. package/tests/verify-agent-contract.test.ts +8 -8
  236. package/tests/vim-editor-adapter.test.ts +41 -15
  237. package/tests/windows-hidden-processes.test.ts +5 -0
  238. package/tests/writer-edit-surface-scope.test.ts +3 -0
  239. package/tests/writer-feature-document-admission.test.ts +156 -0
  240. package/tests/yolo-mode.test.ts +24 -1
  241. package/tests/yolo-temp-scratch.test.ts +71 -0
  242. package/scripts/mirror-odd-routing.mjs +0 -242
  243. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/orchestration/pi.md +0 -0
  244. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/lens.schema.json +0 -0
  245. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/refuter.schema.json +0 -0
  246. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/schemas/targeted-validator.schema.json +0 -0
  247. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/lens.json +0 -0
  248. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/refuter.json +0 -0
  249. /package/contracts/review-provider-contract-mirror/{v1.3.0 → v1.4.0}/bundle/vectors/targeted-validator.json +0 -0
@@ -131,7 +131,10 @@ export function withReverseSpan(row: string, startCu: number, endCu: number): st
131
131
  // A nested SGR reset (e.g. the cursor block's own, when the cursor
132
132
  // sits inside the span) clears reverse for everything after it:
133
133
  // re-arm reverse right after it.
134
- if (opened && cu < endCu && isSgrReset(seq)) out += "\x1b[7m";
134
+ // Pi 1.2 resolves the APC fake-cursor end to SGR 27 later in
135
+ // the host TUI. Preserve the marker for hardware-cursor suppression,
136
+ // but rearm our selection after it just as after a legacy reset.
137
+ if (opened && cu < endCu && (isSgrReset(seq) || seq === "\x1b_pi:/fc\x07")) out += "\x1b[7m";
135
138
  i += seq.length;
136
139
  continue;
137
140
  }
@@ -16,8 +16,8 @@
16
16
  // The live resolution applies only in `follow` mode (GENTLE_PI_PROFILE_FOLLOW=1,
17
17
  // for CI and headless runs), in subagent children (their routing comes from the
18
18
  // parent through --model), and when no session id is available. In `follow`
19
- // mode the orchestrator still comes from the startup profile while children
20
- // follow later defaults: an accepted, documented limitation.
19
+ // mode the orchestrator is still chosen at startup while children follow later
20
+ // defaults: an accepted, documented limitation.
21
21
  //
22
22
  // The frozen state is in-process only, like the explicit binding of slice 2: it
23
23
  // lives on `globalThis` behind a `Symbol.for` key because Pi evaluates this
@@ -27,9 +27,9 @@
27
27
  // `gentle-pi.session-profile/v1` entry with origin `local | repo | global` is
28
28
  // the integration point with slice 3b-i.
29
29
  import { createHash } from "node:crypto";
30
+ import { normalizeModelConfig, type AgentModelConfig } from "./model-routing-authority.ts";
30
31
  import { profileRoleEntries, profilesFilePath, readProfilesFileResult } from "./agent-profiles.ts";
31
32
  import { readProfilePinStatus, resolveProfilePin, resolveUnversionedProjectProfile } from "./agent-profile-pin.ts";
32
- import type { AgentModelConfig } from "./model-routing-authority.ts";
33
33
  import { readSessionProfileBinding } from "./session-profile-binding.ts";
34
34
  import type { WorktreeIdentity, WorktreeResolver } from "./session-worktree-registry.ts";
35
35
 
@@ -114,19 +114,14 @@ function isChildSession(env: NodeJS.ProcessEnv): boolean {
114
114
  * is never part of the routing. `undefined` means no layer has a profile.
115
115
  */
116
116
  export function resolveInheritedProfile(options: InheritedProfileOptions): InheritedProfile | undefined {
117
- // One worktree lookup serves both the pin layers and the non-Git check.
118
- let resolved = false;
119
- let identity: WorktreeIdentity | undefined;
120
- const resolveWorktree: WorktreeResolver | undefined = options.resolveWorktree === undefined ? undefined : (cwd, base) => {
121
- if (!resolved) {
122
- resolved = true;
123
- identity = options.resolveWorktree!(cwd, base);
124
- }
125
- return identity;
126
- };
127
- const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree });
128
- if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
129
- if (readProfilePinStatus(options.cwd, resolveWorktree) === undefined) {
117
+ // One worktree lookup serves both the pin layers and the non-Git check,
118
+ // whether the caller passes a resolver or the pin module's default runs.
119
+ const status = readProfilePinStatus(options.cwd, options.resolveWorktree);
120
+ if (status !== undefined) {
121
+ const identity: WorktreeIdentity = { root: status.root, commonDir: status.commonDir };
122
+ const pin = resolveProfilePin({ cwd: options.cwd, configHome: options.configHome, resolveWorktree: () => identity });
123
+ if (pin !== undefined) return { name: pin.profile, origin: pin.source, modelProfiles: pin.modelProfiles };
124
+ } else {
130
125
  const declaration = resolveUnversionedProjectProfile(options.cwd, options.configHome);
131
126
  if (declaration !== undefined) return { name: declaration.profile, origin: "repo", modelProfiles: declaration.modelProfiles };
132
127
  }
@@ -247,6 +242,7 @@ function describe(profile: InheritedProfile | undefined): string {
247
242
  */
248
243
  export function inheritedProfileDriftNotice(options: SessionProfileOptions): string | undefined {
249
244
  const resolution = resolveSessionProfile(options);
245
+ if (resolution.kind === "explicit") return bindingDefinitionDriftNotice(options, resolution);
250
246
  if (resolution.kind !== "inherited") return undefined;
251
247
  const current = resolveInheritedProfile(options);
252
248
  const key = fingerprint(current);
@@ -259,6 +255,34 @@ export function inheritedProfileDriftNotice(options: SessionProfileOptions): str
259
255
  return `el Gentleman: the default profile changed to ${describe(current)}, this session keeps ${describe(resolution.profile)}. Press Enter on a profile in /gentle:profiles to adopt it.`;
260
256
  }
261
257
 
258
+ // gentle-shell#2228: an explicit binding re-copies the profile definition at
259
+ // selection, so later edits to that entry in profiles.json no-op silently until
260
+ // the operator re-Enters. The same once-per-change fingerprint machine as the
261
+ // inherited notice names the drift; the comparison never adopts the edit.
262
+ function bindingDefinitionDriftNotice(
263
+ options: SessionProfileOptions,
264
+ resolution: { kind: "explicit"; name: string; modelProfiles: AgentModelConfig },
265
+ ): string | undefined {
266
+ const file = readProfilesFileResult(profilesFilePath(options.configHome));
267
+ if (file.status !== "valid") return undefined;
268
+ const definition = file.file.profiles[resolution.name];
269
+ if (definition === undefined) return undefined;
270
+ // Both sides normalize through the store's own route normalizer: a bound
271
+ // snapshot taken from the panel holds normalized routes, while raw string
272
+ // forms from other callers must hash equal to the same definition.
273
+ const normalized = (config: AgentModelConfig) =>
274
+ Object.fromEntries(Object.entries(config).map(([agent, route]) => [agent, normalizeModelConfig(route)]));
275
+ const key = createHash("sha256").update(stableJson({ modelProfiles: normalized(definition) })).digest("hex");
276
+ const current = createHash("sha256").update(stableJson({ modelProfiles: normalized(resolution.modelProfiles) })).digest("hex");
277
+ if (key === current) return undefined;
278
+ const notified = registry().notified;
279
+ const seen = notified.get(options.sessionId!) ?? new Set<string>();
280
+ if (seen.has(key)) return undefined;
281
+ seen.add(key);
282
+ notified.set(options.sessionId!, seen);
283
+ return `el Gentleman: the profile definition changed to "${resolution.name}" (profiles.json), this session keeps the binding from selection. Press Enter on a profile in /gentle:profiles to adopt it.`;
284
+ }
285
+
262
286
  /** Testing-only reset of every frozen snapshot and drift memory. */
263
287
  export function resetFrozenInheritedProfilesForTesting(): void {
264
288
  registry().frozen.clear();
@@ -0,0 +1,187 @@
1
+ // Standalone v1 codec and pure active-branch replay (gentle-shell#1064).
2
+ // No Pi API, disk access, shared binding store, or orchestrator application.
3
+ import { isValidProfileName } from "./agent-profiles.ts";
4
+ import {
5
+ isSafeAgentName,
6
+ isThinkingLevel,
7
+ normalizeModelId,
8
+ normalizeRoutingEntry,
9
+ type AgentModelConfig,
10
+ type AgentRoutingEntry,
11
+ } from "./model-routing-authority.ts";
12
+
13
+ export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1";
14
+ const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/";
15
+
16
+ export type SessionProfileOrigin = "user" | "local" | "repo" | "global";
17
+
18
+ export interface SessionProfileBindPayload {
19
+ kind: "bind";
20
+ origin: SessionProfileOrigin;
21
+ name: string;
22
+ modelProfiles: AgentModelConfig;
23
+ }
24
+
25
+ export interface SessionProfileClearPayload {
26
+ kind: "clear";
27
+ }
28
+
29
+ /** Public structural entry shape only; ordinary messages need no custom fields. */
30
+ export interface SessionProfileEntry {
31
+ type: string;
32
+ customType?: string;
33
+ data?: unknown;
34
+ }
35
+
36
+ export type SessionProfileReadResult =
37
+ | { status: "absent" }
38
+ | { status: "bound"; binding: SessionProfileBindPayload }
39
+ | { status: "cleared" }
40
+ | { status: "invalid" }
41
+ | { status: "unsupported" };
42
+
43
+ export type SessionProfileReplayResult =
44
+ | { status: "absent" }
45
+ | (Exclude<SessionProfileReadResult, { status: "absent" }> & {
46
+ entryIndex: number;
47
+ });
48
+
49
+ function isRecord(value: unknown): value is Record<string, unknown> {
50
+ return typeof value === "object" && value !== null && !Array.isArray(value);
51
+ }
52
+
53
+ function hasOwn(value: Record<string, unknown>, key: string): boolean {
54
+ return Object.hasOwn(value, key);
55
+ }
56
+
57
+ function isOrigin(value: unknown): value is SessionProfileOrigin {
58
+ return (
59
+ value === "user" ||
60
+ value === "local" ||
61
+ value === "repo" ||
62
+ value === "global"
63
+ );
64
+ }
65
+
66
+ /** Validate known fields before the permissive routing helper can drop them. */
67
+ function readRoute(
68
+ value: unknown,
69
+ encoderInput = false,
70
+ ): AgentRoutingEntry | undefined {
71
+ if (typeof value === "string") return normalizeRoutingEntry(value);
72
+ if (!isRecord(value)) return undefined;
73
+ // Typed normalized routes can own optional undefined fields. Omit only those
74
+ // encoder fields; persisted fields and legacy effort retain strict validation.
75
+ const route = encoderInput
76
+ ? Object.fromEntries(
77
+ Object.entries(value).filter(
78
+ ([key, field]) =>
79
+ !((key === "model" || key === "thinking") && field === undefined),
80
+ ),
81
+ )
82
+ : value;
83
+ if (hasOwn(route, "model") && normalizeModelId(route.model) === undefined)
84
+ return undefined;
85
+ if (hasOwn(route, "thinking") && !isThinkingLevel(route.thinking))
86
+ return undefined;
87
+ if (hasOwn(route, "effort") && !isThinkingLevel(route.effort))
88
+ return undefined;
89
+ // Project only own known fields. Unknown extras, even an unknown-only route,
90
+ // carry no routing meaning. Thinking wins over legacy effort as elsewhere.
91
+ const known = Object.fromEntries(
92
+ ["model", "thinking", "effort"]
93
+ .filter((key) => hasOwn(route, key))
94
+ .map((key) => [key, route[key]]),
95
+ );
96
+ const normalized = normalizeRoutingEntry(known);
97
+ if (!normalized) return undefined;
98
+ return Object.fromEntries(
99
+ Object.entries(normalized).filter(([, field]) => field !== undefined),
100
+ );
101
+ }
102
+
103
+ function readSnapshot(
104
+ value: unknown,
105
+ encoderInput = false,
106
+ ): AgentModelConfig | undefined {
107
+ if (!isRecord(value)) return undefined;
108
+ const entries: Array<[string, AgentRoutingEntry]> = [];
109
+ for (const [name, rawRoute] of Object.entries(value)) {
110
+ // Shared agent-name policy, applied here without the forgiving whole-config
111
+ // normalization that would silently discard a bad route. Output below uses
112
+ // own data properties, never prototype setters.
113
+ if (!isSafeAgentName(name)) return undefined;
114
+ const route = readRoute(rawRoute, encoderInput);
115
+ if (route === undefined) return undefined;
116
+ entries.push([name, route]);
117
+ }
118
+ return Object.fromEntries(entries);
119
+ }
120
+
121
+ function readBind(
122
+ data: Record<string, unknown>,
123
+ encoderInput = false,
124
+ ): SessionProfileBindPayload | undefined {
125
+ if (!["origin", "name", "modelProfiles"].every((key) => hasOwn(data, key)))
126
+ return undefined;
127
+ if (!isOrigin(data.origin) || !isValidProfileName(data.name)) return undefined;
128
+ const modelProfiles = readSnapshot(data.modelProfiles, encoderInput);
129
+ if (modelProfiles === undefined) return undefined;
130
+ return { kind: "bind", origin: data.origin, name: data.name, modelProfiles };
131
+ }
132
+
133
+ /** Create a detached user selection payload; never appends or claims durability. */
134
+ export function createSessionProfileBind(
135
+ name: string,
136
+ modelProfiles: AgentModelConfig,
137
+ ): SessionProfileBindPayload {
138
+ const binding = readBind({ origin: "user", name, modelProfiles }, true);
139
+ if (!binding) throw new TypeError("Invalid session profile binding");
140
+ return binding;
141
+ }
142
+
143
+ export function createSessionProfileClear(): SessionProfileClearPayload {
144
+ return { kind: "clear" };
145
+ }
146
+
147
+ /** Unknown family identifiers are terminal unsupported states, regardless of data. */
148
+ export function readSessionProfileEntry(
149
+ entry: SessionProfileEntry,
150
+ ): SessionProfileReadResult {
151
+ if (
152
+ entry.type !== "custom" ||
153
+ typeof entry.customType !== "string" ||
154
+ !entry.customType.startsWith(SESSION_PROFILE_FAMILY)
155
+ )
156
+ return { status: "absent" };
157
+ if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE)
158
+ return { status: "unsupported" };
159
+ const data = entry.data;
160
+ if (!isRecord(data) || !hasOwn(data, "kind")) return { status: "invalid" };
161
+ if (data.kind === "clear") return { status: "cleared" };
162
+ if (data.kind !== "bind") return { status: "invalid" };
163
+ const binding = readBind(data);
164
+ return binding ? { status: "bound", binding } : { status: "invalid" };
165
+ }
166
+
167
+ /**
168
+ * Caller MUST supply ALREADY disk-corroborated active-branch entries, in branch
169
+ * order (oldest to newest). This function cannot establish that precondition:
170
+ * it does not read disk, call Pi, enforce append-failure recovery, establish
171
+ * durability, or update the shared map. In-memory getBranch() alone is NOT
172
+ * corroboration. Sibling and future paths must be excluded by the caller.
173
+ *
174
+ * The newest family entry wins even when invalid or unsupported; never revive
175
+ * an older binding. Cleared permits current fallback at a later consumer,
176
+ * whereas invalid/unsupported must remain distinct from absent. entryIndex is
177
+ * the actual supplied branch offset for a later adapter's location warning.
178
+ */
179
+ export function replaySessionProfileBranch(
180
+ entries: readonly SessionProfileEntry[],
181
+ ): SessionProfileReplayResult {
182
+ for (let entryIndex = entries.length - 1; entryIndex >= 0; entryIndex--) {
183
+ const result = readSessionProfileEntry(entries[entryIndex]);
184
+ if (result.status !== "absent") return { ...result, entryIndex };
185
+ }
186
+ return { status: "absent" };
187
+ }