Repository navigation
feat(profiles): add session profile v1 record decoder #1918
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
barbatdev
merged 2 commits into
Gentleman-Programming:main
from
noxsystems:feat/1064-3b-i-1-codec-decoder
Oct 12, 2026
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| # Session profile record format | ||
|
|
||
| `lib/session-profile-persistence.ts` defines the optional session profile v1 | ||
| record format and its decoder. Adding this module does not change Enter, | ||
| startup, routing, shared defaults, or live orchestrator behavior. | ||
|
|
||
| ## Payloads | ||
|
|
||
| A custom entry uses `customType: "gentle-pi.session-profile/v1"` and one payload: | ||
|
|
||
| ```json | ||
| {"kind":"bind","origin":"user","name":"work","modelProfiles":{"worker":{"model":"provider/model"},"orchestrator":{"thinking":"high"}}} | ||
| {"kind":"clear"} | ||
| ``` | ||
|
|
||
| The decoder accepts the closed origin set `user`, `local`, `repo`, and | ||
| `global`; this does not implement inherited startup persistence or follow | ||
| mode. A bound empty snapshot is not a clear. Snapshots are detached, known | ||
| invalid fields reject the whole binding, and unknown extra fields carry no | ||
| routing meaning. Existing route normalization supports legacy model strings | ||
| and `effort`; valid `thinking` takes precedence. | ||
|
|
||
| ## Decoding one entry | ||
|
|
||
| `readSessionProfileEntry` classifies one supplied entry. It neither reads disk | ||
| nor establishes that the entry was persisted: | ||
|
|
||
| | Result | Meaning | | ||
| | --- | --- | | ||
| | `absent` | Not a profile-family custom entry. | | ||
| | `bound` | A detached, validated binding, including an empty snapshot. | | ||
| | `cleared` | An explicit clear; fallback is a later consumer's decision. | | ||
| | `invalid` | Invalid v1 data. | | ||
| | `unsupported` | Unknown profile-family identifier, regardless of payload. | | ||
|
|
||
| The encoder and the active-branch replay rule are a subsequent slice. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| // Standalone v1 codec decoder (gentle-shell#1064). | ||
| // No Pi API, disk access, shared binding store, or orchestrator application. | ||
| import { isValidProfileName } from "./agent-profiles.ts"; | ||
| import { | ||
| isSafeAgentName, | ||
| isThinkingLevel, | ||
| normalizeModelId, | ||
| normalizeRoutingEntry, | ||
| type AgentModelConfig, | ||
| type AgentRoutingEntry, | ||
| } from "./model-routing-authority.ts"; | ||
|
|
||
| export const SESSION_PROFILE_CUSTOM_TYPE = "gentle-pi.session-profile/v1"; | ||
| const SESSION_PROFILE_FAMILY = "gentle-pi.session-profile/"; | ||
|
|
||
| export type SessionProfileOrigin = "user" | "local" | "repo" | "global"; | ||
|
|
||
| export interface SessionProfileBindPayload { | ||
| kind: "bind"; | ||
| origin: SessionProfileOrigin; | ||
| name: string; | ||
| modelProfiles: AgentModelConfig; | ||
| } | ||
|
|
||
| export interface SessionProfileClearPayload { | ||
| kind: "clear"; | ||
| } | ||
|
|
||
| /** Public structural entry shape only; ordinary messages need no custom fields. */ | ||
| export interface SessionProfileEntry { | ||
| type: string; | ||
| customType?: string; | ||
| data?: unknown; | ||
| } | ||
|
|
||
| export type SessionProfileReadResult = | ||
| | { status: "absent" } | ||
| | { status: "bound"; binding: SessionProfileBindPayload } | ||
| | { status: "cleared" } | ||
| | { status: "invalid" } | ||
| | { status: "unsupported" }; | ||
|
|
||
| function isRecord(value: unknown): value is Record<string, unknown> { | ||
| return typeof value === "object" && value !== null && !Array.isArray(value); | ||
| } | ||
|
|
||
| function hasOwn(value: Record<string, unknown>, key: string): boolean { | ||
| return Object.hasOwn(value, key); | ||
| } | ||
|
|
||
| function isOrigin(value: unknown): value is SessionProfileOrigin { | ||
| return ( | ||
| value === "user" || | ||
| value === "local" || | ||
| value === "repo" || | ||
| value === "global" | ||
| ); | ||
| } | ||
|
|
||
| /** Validate known fields before the permissive routing helper can drop them. */ | ||
| function readRoute(value: unknown): AgentRoutingEntry | undefined { | ||
| if (typeof value === "string") return normalizeRoutingEntry(value); | ||
| if (!isRecord(value)) return undefined; | ||
| const route = value; | ||
| if (hasOwn(route, "model") && normalizeModelId(route.model) === undefined) | ||
| return undefined; | ||
| if (hasOwn(route, "thinking") && !isThinkingLevel(route.thinking)) | ||
| return undefined; | ||
| if (hasOwn(route, "effort") && !isThinkingLevel(route.effort)) | ||
| return undefined; | ||
| // Project only own known fields. Unknown extras, even an unknown-only route, | ||
| // carry no routing meaning. Thinking wins over legacy effort as elsewhere. | ||
| const known = Object.fromEntries( | ||
| ["model", "thinking", "effort"] | ||
| .filter((key) => hasOwn(route, key)) | ||
| .map((key) => [key, route[key]]), | ||
| ); | ||
| const normalized = normalizeRoutingEntry(known); | ||
| if (!normalized) return undefined; | ||
| return Object.fromEntries( | ||
| Object.entries(normalized).filter(([, field]) => field !== undefined), | ||
| ); | ||
| } | ||
|
|
||
| function readSnapshot(value: unknown): AgentModelConfig | undefined { | ||
| if (!isRecord(value)) return undefined; | ||
| const entries: Array<[string, AgentRoutingEntry]> = []; | ||
| for (const [name, rawRoute] of Object.entries(value)) { | ||
| // Shared agent-name policy, applied here without the forgiving whole-config | ||
| // normalization that would silently discard a bad route. Output below uses | ||
| // own data properties, never prototype setters. | ||
| if (!isSafeAgentName(name)) return undefined; | ||
| const route = readRoute(rawRoute); | ||
| if (route === undefined) return undefined; | ||
| entries.push([name, route]); | ||
| } | ||
| return Object.fromEntries(entries); | ||
| } | ||
|
|
||
| function readBind( | ||
| data: Record<string, unknown>, | ||
| ): SessionProfileBindPayload | undefined { | ||
| if (!["origin", "name", "modelProfiles"].every((key) => hasOwn(data, key))) | ||
| return undefined; | ||
| if (!isOrigin(data.origin) || !isValidProfileName(data.name)) return undefined; | ||
| const modelProfiles = readSnapshot(data.modelProfiles); | ||
| if (modelProfiles === undefined) return undefined; | ||
| return { kind: "bind", origin: data.origin, name: data.name, modelProfiles }; | ||
| } | ||
|
|
||
| /** Unknown family identifiers are terminal unsupported states, regardless of data. */ | ||
| export function readSessionProfileEntry( | ||
| entry: SessionProfileEntry, | ||
| ): SessionProfileReadResult { | ||
| if ( | ||
| entry.type !== "custom" || | ||
| typeof entry.customType !== "string" || | ||
| !entry.customType.startsWith(SESSION_PROFILE_FAMILY) | ||
| ) | ||
| return { status: "absent" }; | ||
| if (entry.customType !== SESSION_PROFILE_CUSTOM_TYPE) | ||
| return { status: "unsupported" }; | ||
| const data = entry.data; | ||
| if (!isRecord(data) || !hasOwn(data, "kind")) return { status: "invalid" }; | ||
| if (data.kind === "clear") return { status: "cleared" }; | ||
| if (data.kind !== "bind") return { status: "invalid" }; | ||
| const binding = readBind(data); | ||
| return binding ? { status: "bound", binding } : { status: "invalid" }; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,207 @@ | ||
| import assert from "node:assert/strict"; | ||
| import test from "node:test"; | ||
| import { | ||
| SESSION_PROFILE_CUSTOM_TYPE, | ||
| readSessionProfileEntry, | ||
| } from "../lib/session-profile-persistence.ts"; | ||
|
|
||
| const custom = (data: unknown, customType = SESSION_PROFILE_CUSTOM_TYPE) => ({ | ||
| type: "custom", | ||
| customType, | ||
| data, | ||
| }); | ||
| const bind = (modelProfiles: unknown = {}) => ({ | ||
| kind: "bind", | ||
| origin: "user", | ||
| name: "work", | ||
| modelProfiles, | ||
| }); | ||
|
|
||
| // These are supplied entries, not evidence of persistence on disk. | ||
| test("custom type identifies the v1 session profile family", () => { | ||
| assert.equal(SESSION_PROFILE_CUSTOM_TYPE, "gentle-pi.session-profile/v1"); | ||
| }); | ||
|
|
||
| test("decoder stays strict about present undefined known fields", () => { | ||
| for (const route of [ | ||
| { model: undefined }, | ||
| { thinking: undefined }, | ||
| { effort: undefined }, | ||
| ]) { | ||
| assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), { | ||
| status: "invalid", | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| test("bound empty snapshot is distinct from cleared", () => { | ||
| assert.deepEqual(readSessionProfileEntry(custom(bind())), { | ||
| status: "bound", | ||
| binding: bind(), | ||
| }); | ||
| assert.deepEqual(readSessionProfileEntry(custom({ kind: "clear" })), { | ||
| status: "cleared", | ||
| }); | ||
| }); | ||
|
|
||
| test("reader accepts all four origins and ignores unknown payload fields", () => { | ||
| for (const origin of ["user", "local", "repo", "global"]) { | ||
| assert.deepEqual( | ||
| readSessionProfileEntry( | ||
| custom({ ...bind(), origin, extra: true, version: 99 }), | ||
| ), | ||
| { | ||
| status: "bound", | ||
| binding: { ...bind(), origin }, | ||
| }, | ||
| ); | ||
| } | ||
| assert.deepEqual( | ||
| readSessionProfileEntry(custom({ kind: "clear", extra: true })), | ||
| { status: "cleared" }, | ||
| ); | ||
| }); | ||
|
|
||
| test("legacy strings and effort normalize while orchestrator remains in the snapshot", () => { | ||
| const result = readSessionProfileEntry( | ||
| custom( | ||
| bind({ | ||
| worker: " provider/model ", | ||
| reviewer: { effort: "high", ignored: true }, | ||
| orchestrator: { model: "provider/main", thinking: "low", effort: "max" }, | ||
| inheriting: {}, | ||
| }), | ||
| ), | ||
| ); | ||
| assert.deepEqual(result, { | ||
| status: "bound", | ||
| binding: bind({ | ||
| worker: { model: "provider/model" }, | ||
| reviewer: { thinking: "high" }, | ||
| orchestrator: { model: "provider/main", thinking: "low" }, | ||
| inheriting: {}, | ||
| }), | ||
| }); | ||
| }); | ||
|
|
||
| test("missing required fields and invalid known meanings fail closed", () => { | ||
| const invalid: unknown[] = [ | ||
| null, | ||
| [], | ||
| {}, | ||
| { kind: "other" }, | ||
| { kind: "bind", name: "work", modelProfiles: {} }, | ||
| { ...bind(), origin: "future" }, | ||
| { ...bind(), name: "../work" }, | ||
| { ...bind(), name: "constructor" }, | ||
| { ...bind(), modelProfiles: undefined }, | ||
| { ...bind(), modelProfiles: [] }, | ||
| ]; | ||
| for (const data of invalid) { | ||
| assert.deepEqual(readSessionProfileEntry(custom(data)), { | ||
| status: "invalid", | ||
| }); | ||
| } | ||
| }); | ||
|
|
||
| test("one invalid route invalidates the whole snapshot instead of silently dropping it", () => { | ||
| const routes: unknown[] = [ | ||
| null, | ||
| [], | ||
| 3, | ||
| "bad model", | ||
| { model: "bad model", thinking: "low" }, | ||
| { model: "provider/good", thinking: "extreme" }, | ||
| { effort: "extreme" }, | ||
| { thinking: "low", effort: "extreme" }, | ||
| { model: null }, | ||
| ]; | ||
| for (const route of routes) { | ||
| assert.deepEqual(readSessionProfileEntry(custom(bind({ worker: route }))), { | ||
| status: "invalid", | ||
| }); | ||
| assert.deepEqual( | ||
| readSessionProfileEntry( | ||
| custom(bind({ good: "provider/good", worker: route })), | ||
| ), | ||
| { status: "invalid" }, | ||
| ); | ||
| } | ||
| assert.deepEqual(readSessionProfileEntry(custom(bind({ "bad role": {} }))), { | ||
| status: "invalid", | ||
| }); | ||
| }); | ||
|
|
||
| test("unknown route fields are ignored, including an unknown-only route", () => { | ||
| assert.deepEqual( | ||
| readSessionProfileEntry(custom(bind({ worker: { future: true } }))), | ||
| { | ||
| status: "bound", | ||
| binding: bind({ worker: {} }), | ||
| }, | ||
| ); | ||
| }); | ||
|
|
||
| test("hostile record keys remain own data without changing prototypes", () => { | ||
| const routes = JSON.parse( | ||
| '{"__proto__":{"model":"provider/safe"},"constructor":{},"prototype":{}}', | ||
| ); | ||
| const result = readSessionProfileEntry(custom(bind(routes))); | ||
| assert.equal(result.status, "bound"); | ||
| if (result.status !== "bound") return; | ||
| assert.equal( | ||
| Object.getPrototypeOf(result.binding.modelProfiles), | ||
| Object.prototype, | ||
| ); | ||
| assert.equal(Object.hasOwn(result.binding.modelProfiles, "__proto__"), true); | ||
| assert.deepEqual(result.binding.modelProfiles["__proto__"], { | ||
| model: "provider/safe", | ||
| }); | ||
| for (const key of ["constructor", "prototype"]) { | ||
| assert.equal(Object.hasOwn(result.binding.modelProfiles, key), true); | ||
| assert.deepEqual(result.binding.modelProfiles[key], {}); | ||
| } | ||
| }); | ||
|
|
||
| test("required fields cannot be supplied by prototypes", () => { | ||
| assert.deepEqual(readSessionProfileEntry(custom(Object.create(bind()))), { | ||
| status: "invalid", | ||
| }); | ||
| }); | ||
|
|
||
| test("reader returns defensive snapshots", () => { | ||
| const entry = custom(bind({ worker: { model: "provider/first" } })); | ||
| const first = readSessionProfileEntry(entry); | ||
| assert.equal(first.status, "bound"); | ||
| if (first.status !== "bound") return; | ||
| first.binding.modelProfiles.worker.model = "provider/third"; | ||
| const second = readSessionProfileEntry(entry); | ||
| assert.equal(second.status, "bound"); | ||
| if (second.status === "bound") | ||
| assert.equal(second.binding.modelProfiles.worker.model, "provider/first"); | ||
| }); | ||
|
|
||
| test("noncustom and unrelated custom entries are absent even with matching payloads", () => { | ||
| for (const entry of [ | ||
| { type: "message", customType: SESSION_PROFILE_CUSTOM_TYPE, data: bind() }, | ||
| { type: "message", message: { role: "user", content: "hello" } }, | ||
| custom(bind(), "other/v1"), | ||
| ]) | ||
| assert.deepEqual(readSessionProfileEntry(entry), { status: "absent" }); | ||
| }); | ||
|
|
||
| test("family identifier, not payload version, determines support", () => { | ||
| for (const customType of [ | ||
| "gentle-pi.session-profile/v0", | ||
| "gentle-pi.session-profile/v99", | ||
| "gentle-pi.session-profile/future", | ||
| ]) { | ||
| assert.deepEqual(readSessionProfileEntry(custom(null, customType)), { | ||
| status: "unsupported", | ||
| }); | ||
| } | ||
| assert.deepEqual( | ||
| readSessionProfileEntry(custom(bind(), "gentle-pi.session-profile-other/v1")), | ||
| { status: "absent" }, | ||
| ); | ||
| }); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.