diff --git a/docs/session-profile-format.md b/docs/session-profile-format.md new file mode 100644 index 000000000..1fc67893a --- /dev/null +++ b/docs/session-profile-format.md @@ -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. diff --git a/lib/model-routing-authority.ts b/lib/model-routing-authority.ts index ee2a30c02..eb4f08786 100644 --- a/lib/model-routing-authority.ts +++ b/lib/model-routing-authority.ts @@ -28,6 +28,11 @@ export type ModelConfigFileResult = export const SAFE_MODEL_ID_PATTERN = /^[A-Za-z0-9._~:@/+%-]+$/; const SAFE_AGENT_NAME_PATTERN = /^[A-Za-z0-9._:@/+%-]+$/; +/** Shared agent-name policy; the same test normalizeModelConfig applies per key. */ +export function isSafeAgentName(name: string): boolean { + return SAFE_AGENT_NAME_PATTERN.test(name); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -78,7 +83,7 @@ export function normalizeModelConfig(value: unknown): AgentModelConfig | undefin if (!isRecord(value)) return undefined; const cleaned: AgentModelConfig = {}; for (const [name, entryValue] of Object.entries(value)) { - if (!SAFE_AGENT_NAME_PATTERN.test(name)) continue; + if (!isSafeAgentName(name)) continue; const entry = normalizeRoutingEntry(entryValue); if (entry) cleaned[name] = entry; } diff --git a/lib/session-profile-persistence.ts b/lib/session-profile-persistence.ts new file mode 100644 index 000000000..3de8137de --- /dev/null +++ b/lib/session-profile-persistence.ts @@ -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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOwn(value: Record, 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, +): 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" }; +} diff --git a/tests/session-profile-persistence.test.ts b/tests/session-profile-persistence.test.ts new file mode 100644 index 000000000..214e94d81 --- /dev/null +++ b/tests/session-profile-persistence.test.ts @@ -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" }, + ); +});