feat: add answer extraction replay commands

This commit is contained in:
Erik Kotsiuba
2026-05-02 22:47:55 +02:00
parent 288caf49de
commit 5b70eca306
36 changed files with 1636 additions and 98 deletions
+69 -4
View File
@@ -1,7 +1,20 @@
import { DEFAULT_ASK_KEYMAPS } from "../constants/keymaps.ts";
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
import type { AskConfig, AskConfigFileV2 } from "./schema.ts";
const DEFAULT_EXTRACTION_RETRIES = 1;
const MAX_EXTRACTION_RETRIES = 3;
const DEFAULT_EXTRACTION_TIMEOUT_MS = 30_000;
export const DEFAULT_ASK_CONFIG: AskConfig = {
answer: {
extractionModels: [
{ provider: "openai-codex", id: "gpt-5.4-mini" },
{ provider: "github-copilot", id: "gpt-5.4-mini" },
{ provider: "anthropic", id: "claude-haiku-4-5" },
],
extractionRetries: DEFAULT_EXTRACTION_RETRIES,
extractionTimeoutMs: DEFAULT_EXTRACTION_TIMEOUT_MS,
},
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: false,
confirmDismissWhenDirty: true,
@@ -14,9 +27,24 @@ export const DEFAULT_ASK_CONFIG: AskConfig = {
};
export function normalizeAskConfig(
config?: Partial<AskConfigFileV1> | AskConfig
config?: Partial<AskConfigFileV2> | AskConfig
): AskConfig {
return {
answer: {
extractionModels:
config?.answer?.extractionModels?.filter(isValidModelPreference) ??
DEFAULT_ASK_CONFIG.answer.extractionModels,
extractionRetries: clampInteger(
config?.answer?.extractionRetries,
0,
MAX_EXTRACTION_RETRIES,
DEFAULT_EXTRACTION_RETRIES
),
extractionTimeoutMs: positiveNumberOrDefault(
config?.answer?.extractionTimeoutMs,
DEFAULT_EXTRACTION_TIMEOUT_MS
),
},
behaviour: {
autoSubmitWhenAnsweredWithoutNotes:
config?.behaviour?.autoSubmitWhenAnsweredWithoutNotes ??
@@ -38,10 +66,15 @@ export function normalizeAskConfig(
};
}
export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
export function toAskConfigFileV2(config: AskConfig): AskConfigFileV2 {
const normalized = normalizeAskConfig(config);
return {
schemaVersion: 1,
schemaVersion: 2,
answer: {
extractionModels: normalized.answer.extractionModels,
extractionRetries: normalized.answer.extractionRetries,
extractionTimeoutMs: normalized.answer.extractionTimeoutMs,
},
behaviour: {
autoSubmitWhenAnsweredWithoutNotes:
normalized.behaviour.autoSubmitWhenAnsweredWithoutNotes,
@@ -60,3 +93,35 @@ export function toAskConfigFileV1(config: AskConfig): AskConfigFileV1 {
},
};
}
function isValidModelPreference(value: unknown): value is {
id: string;
provider: string;
} {
return (
!!value &&
typeof value === "object" &&
typeof (value as { id?: unknown }).id === "string" &&
(value as { id: string }).id.trim().length > 0 &&
typeof (value as { provider?: unknown }).provider === "string" &&
(value as { provider: string }).provider.trim().length > 0
);
}
function clampInteger(
value: unknown,
min: number,
max: number,
fallback: number
): number {
if (typeof value !== "number" || !Number.isFinite(value)) {
return fallback;
}
return Math.max(min, Math.min(max, Math.trunc(value)));
}
function positiveNumberOrDefault(value: unknown, fallback: number): number {
return typeof value === "number" && Number.isFinite(value) && value > 0
? value
: fallback;
}
+4 -4
View File
@@ -8,8 +8,8 @@ import {
type AskConfigVersionMigrationResult,
migrateAskConfigFileToCurrent,
} from "./migrations/index.ts";
import type { AskConfig, AskConfigFileV1 } from "./schema.ts";
import { validateAskConfigFileV1 } from "./schema.ts";
import type { AskConfig, AskConfigFileV2 } from "./schema.ts";
import { validateAskConfigFileV2 } from "./schema.ts";
export class AskConfigMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed";
@@ -42,14 +42,14 @@ export function migrateAskConfig(raw: unknown): AskConfigMigrationResult {
);
}
if (!validateAskConfigFileV1.Check(migratedFile.config)) {
if (!validateAskConfigFileV2.Check(migratedFile.config)) {
throw new AskConfigMigrationError(
"Config was invalid or unsupported.",
"invalid_or_unsupported"
);
}
const currentFile = migratedFile.config as AskConfigFileV1;
const currentFile = migratedFile.config as AskConfigFileV2;
const config = normalizeAskConfig(currentFile);
const keymapsResult = normalizeConfiguredKeymaps(
currentFile.keymaps as
+11 -2
View File
@@ -1,8 +1,17 @@
import type { AskConfigMigration, VersionedAskConfigFile } from "./types.ts";
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 1;
export const CURRENT_ASK_CONFIG_SCHEMA_VERSION = 2;
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [];
const ASK_CONFIG_MIGRATIONS: AskConfigMigration[] = [
{
from: 1,
to: 2,
migrate: (config) => ({
...config,
schemaVersion: 2,
}),
},
];
export class AskConfigVersionMigrationError extends Error {
readonly reason: "invalid_or_unsupported" | "migration_failed";
+36 -6
View File
@@ -1,8 +1,22 @@
import { type Static, Type } from "typebox";
import { Compile } from "typebox/compile";
export const AskConfigFileV1Schema = Type.Object({
schemaVersion: Type.Literal(1),
const AskAnswerModelPreferenceSchema = Type.Object({
id: Type.String(),
provider: Type.String(),
});
export const AskConfigFileV2Schema = Type.Object({
schemaVersion: Type.Literal(2),
answer: Type.Optional(
Type.Object({
extractionModels: Type.Optional(
Type.Array(AskAnswerModelPreferenceSchema)
),
extractionRetries: Type.Optional(Type.Number()),
extractionTimeoutMs: Type.Optional(Type.Number()),
})
),
behaviour: Type.Optional(
Type.Object({
autoSubmitWhenAnsweredWithoutNotes: Type.Optional(Type.Boolean()),
@@ -14,16 +28,27 @@ export const AskConfigFileV1Schema = Type.Object({
keymaps: Type.Optional(
Type.Object({
cancel: Type.Optional(Type.String()),
dismiss: Type.Optional(Type.String()),
toggle: Type.Optional(Type.String()),
confirm: Type.Optional(Type.String()),
dismiss: Type.Optional(Type.String()),
optionNote: Type.Optional(Type.String()),
questionNote: Type.Optional(Type.String()),
toggle: Type.Optional(Type.String()),
})
),
});
export type AskConfigFileV1 = Static<typeof AskConfigFileV1Schema>;
export type AskConfigFileV2 = Static<typeof AskConfigFileV2Schema>;
export type AskConfigFileV1 = Omit<
AskConfigFileV2,
"answer" | "schemaVersion"
> & {
schemaVersion: 1;
};
export interface AskAnswerModelPreference {
id: string;
provider: string;
}
export interface AskConfigKeymaps {
cancel: string;
@@ -35,6 +60,11 @@ export interface AskConfigKeymaps {
}
export interface AskConfig {
answer: {
extractionModels: AskAnswerModelPreference[];
extractionRetries: number;
extractionTimeoutMs: number;
};
behaviour: {
autoSubmitWhenAnsweredWithoutNotes: boolean;
confirmDismissWhenDirty: boolean;
@@ -44,4 +74,4 @@ export interface AskConfig {
keymaps: AskConfigKeymaps;
}
export const validateAskConfigFileV1 = Compile(AskConfigFileV1Schema);
export const validateAskConfigFileV2 = Compile(AskConfigFileV2Schema);
+3 -3
View File
@@ -7,7 +7,7 @@ import { getAgentDir } from "@mariozechner/pi-coding-agent";
import {
DEFAULT_ASK_CONFIG,
normalizeAskConfig,
toAskConfigFileV1,
toAskConfigFileV2,
} from "./defaults.ts";
import { AskConfigMigrationError, migrateAskConfig } from "./migrate.ts";
import { migrateAskConfigPathIfNeeded } from "./path-migrations.ts";
@@ -63,10 +63,10 @@ export class AskConfigStore {
return (await this.ensureLoaded()).config;
}
async save(config: AskConfig): Promise<AskConfig> {
async save(config: AskConfig | Partial<AskConfig>): Promise<AskConfig> {
const normalized = normalizeAskConfig(config);
const content = JSON.stringify(
toAskConfigFileV1(normalized),
toAskConfigFileV2(normalized),
null,
2
).concat("\n");