portable-agent-layer 0.108.0 → 0.110.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.
- package/README.md +3 -2
- package/assets/skills/scientific-research/SKILL.md +58 -12
- package/package.json +6 -2
- package/src/cli/correction.ts +115 -0
- package/src/cli/infer.ts +1 -1
- package/src/cli/rule.ts +121 -14
- package/src/cli/tree.ts +2 -0
- package/src/hooks/handlers/rating.ts +21 -2
- package/src/hooks/handlers/update-check.ts +6 -1
- package/src/hooks/lib/adaptation-candidates.ts +41 -13
- package/src/hooks/lib/adaptation-rules.ts +138 -9
- package/src/hooks/lib/adaptation-steering.ts +7 -2
- package/src/hooks/lib/adaptation-turns.ts +43 -1
- package/src/hooks/lib/context.ts +2 -2
- package/src/hooks/lib/correction-backfill.ts +56 -0
- package/src/hooks/lib/correction-kind.ts +165 -0
- package/src/hooks/lib/correction-rates.ts +151 -0
- package/src/hooks/lib/inference.ts +43 -18
- package/src/hooks/lib/models.ts +76 -28
- package/src/hooks/lib/opinion-gate.ts +73 -0
- package/src/hooks/lib/rule-drafter.ts +57 -24
- package/src/hooks/lib/semi-static.ts +2 -2
- package/src/tools/control-room/relationship.ts +43 -18
- package/src/tools/control-room/server.ts +12 -5
- package/src/tools/control-room/ui/dist/assets/{index-mFpXpiBj.js → index-D0pEd7Ln.js} +1 -1
- package/src/tools/control-room/ui/dist/index.html +1 -1
- package/src/tools/control-room/ui/lib/write.ts +3 -1
- package/src/tools/control-room/ui/screens/relationship.tsx +101 -29
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How often each kind of correction happens, per 100 labelled turns. An opinion
|
|
3
|
+
* works when the rate of its kind drops after it is approved.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { type AdaptationRule, isOpinion } from "./adaptation-rules";
|
|
7
|
+
import type { Turn } from "./adaptation-turns";
|
|
8
|
+
import { hasCurrentKind } from "./correction-kind";
|
|
9
|
+
|
|
10
|
+
interface KindRate {
|
|
11
|
+
kind: string;
|
|
12
|
+
count: number;
|
|
13
|
+
per100: number;
|
|
14
|
+
suggestions: string[];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface KindRates {
|
|
18
|
+
turns: number;
|
|
19
|
+
corrections: number;
|
|
20
|
+
unlabelled: number;
|
|
21
|
+
/** Labelled by an older prompt or kind set; backfill relabels them. */
|
|
22
|
+
stale: number;
|
|
23
|
+
kinds: KindRate[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function confirmedCorrections(turns: Turn[]): Turn[] {
|
|
27
|
+
return turns.filter((turn) => turn.confirmed === true);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function per100(count: number, turns: number): number {
|
|
31
|
+
return turns === 0 ? 0 : Math.round((count / turns) * 1000) / 10;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function rateOf(kind: string, labelled: Turn[], turns: number): KindRate {
|
|
35
|
+
const ofKind = labelled.filter((turn) => turn.correctionKind?.kind === kind);
|
|
36
|
+
const suggestions = ofKind
|
|
37
|
+
.map((turn) => turn.correctionKind?.suggested ?? "")
|
|
38
|
+
.filter((name) => name !== "");
|
|
39
|
+
return {
|
|
40
|
+
kind,
|
|
41
|
+
count: ofKind.length,
|
|
42
|
+
per100: per100(ofKind.length, turns),
|
|
43
|
+
suggestions,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function kindRates(turns: Turn[]): KindRates {
|
|
48
|
+
const corrections = confirmedCorrections(turns);
|
|
49
|
+
const labelled = corrections.filter((turn) => turn.correctionKind);
|
|
50
|
+
const kinds = [...new Set(labelled.map((turn) => turn.correctionKind?.kind ?? ""))]
|
|
51
|
+
.map((kind) => rateOf(kind, labelled, turns.length))
|
|
52
|
+
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
|
53
|
+
return {
|
|
54
|
+
turns: turns.length,
|
|
55
|
+
corrections: corrections.length,
|
|
56
|
+
unlabelled: corrections.length - labelled.length,
|
|
57
|
+
stale: labelled.filter((turn) => !hasCurrentKind(turn)).length,
|
|
58
|
+
kinds,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const MIN_TURNS_AFTER = 200;
|
|
63
|
+
const MIN_CORRECTIONS_BEFORE = 5;
|
|
64
|
+
const MAX_WINDOW_MS = 30 * 86_400_000;
|
|
65
|
+
|
|
66
|
+
interface WindowRate {
|
|
67
|
+
turns: number;
|
|
68
|
+
count: number;
|
|
69
|
+
per100: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Windows {
|
|
73
|
+
before: WindowRate;
|
|
74
|
+
after: WindowRate;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface OpinionEffect extends Windows {
|
|
78
|
+
kind: string;
|
|
79
|
+
/** The kinds no opinion targets, over the same windows: what changed anyway. */
|
|
80
|
+
control: Windows;
|
|
81
|
+
verdict: "stale-labels" | "too-early" | "too-few" | "lower" | "not-lower";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function hasStaleLabels(turns: Turn[]): boolean {
|
|
85
|
+
return confirmedCorrections(turns).some((turn) => !hasCurrentKind(turn));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function windowRate(counts: (kind: string) => boolean, turns: Turn[]): WindowRate {
|
|
89
|
+
const count = kindRates(turns)
|
|
90
|
+
.kinds.filter((k) => counts(k.kind))
|
|
91
|
+
.reduce((sum, k) => sum + k.count, 0);
|
|
92
|
+
return { turns: turns.length, count, per100: per100(count, turns.length) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function ratio({ before, after }: Windows): number {
|
|
96
|
+
return before.per100 === 0 ? 1 : after.per100 / before.per100;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function effectVerdict(own: Windows, control: Windows): OpinionEffect["verdict"] {
|
|
100
|
+
if (own.after.turns < MIN_TURNS_AFTER) return "too-early";
|
|
101
|
+
if (own.before.count < MIN_CORRECTIONS_BEFORE) return "too-few";
|
|
102
|
+
return ratio(own) < ratio(control) ? "lower" : "not-lower";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function opinionEffect(
|
|
106
|
+
kind: string,
|
|
107
|
+
approvedAt: Date,
|
|
108
|
+
turns: Turn[],
|
|
109
|
+
opinionKinds: string[] = [kind],
|
|
110
|
+
now: Date = new Date()
|
|
111
|
+
): OpinionEffect {
|
|
112
|
+
const cut = approvedAt.getTime();
|
|
113
|
+
const span = Math.min(MAX_WINDOW_MS, now.getTime() - cut);
|
|
114
|
+
const at = (turn: Turn) => Date.parse(turn.ts);
|
|
115
|
+
const beforeTurns = turns.filter((t) => at(t) >= cut - span && at(t) < cut);
|
|
116
|
+
const afterTurns = turns.filter((t) => at(t) >= cut && at(t) < cut + span);
|
|
117
|
+
const windows = (counts: (k: string) => boolean): Windows => ({
|
|
118
|
+
before: windowRate(counts, beforeTurns),
|
|
119
|
+
after: windowRate(counts, afterTurns),
|
|
120
|
+
});
|
|
121
|
+
const own = windows((k) => k === kind);
|
|
122
|
+
const control = windows((k) => k !== kind && !opinionKinds.includes(k));
|
|
123
|
+
const verdict = hasStaleLabels([...beforeTurns, ...afterTurns])
|
|
124
|
+
? "stale-labels"
|
|
125
|
+
: effectVerdict(own, control);
|
|
126
|
+
return { kind, ...own, control, verdict };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const EFFECT_WINDOW_DAYS = (2 * MAX_WINDOW_MS) / 86_400_000;
|
|
130
|
+
|
|
131
|
+
export function opinionEffectOf(
|
|
132
|
+
rule: AdaptationRule,
|
|
133
|
+
rules: AdaptationRule[],
|
|
134
|
+
turns: Turn[],
|
|
135
|
+
now: Date = new Date()
|
|
136
|
+
): OpinionEffect | null {
|
|
137
|
+
if (!isOpinion(rule) || !rule.kind || !rule.decidedAt) return null;
|
|
138
|
+
const opinionKinds = rules.filter(isOpinion).flatMap((r) => (r.kind ? [r.kind] : []));
|
|
139
|
+
return opinionEffect(rule.kind, new Date(rule.decidedAt), turns, opinionKinds, now);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function splitRates(
|
|
143
|
+
turns: Turn[],
|
|
144
|
+
at: Date
|
|
145
|
+
): { before: KindRates; after: KindRates } {
|
|
146
|
+
const cut = at.getTime();
|
|
147
|
+
return {
|
|
148
|
+
before: kindRates(turns.filter((turn) => Date.parse(turn.ts) < cut)),
|
|
149
|
+
after: kindRates(turns.filter((turn) => Date.parse(turn.ts) >= cut)),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
@@ -62,6 +62,7 @@ import { logDebug, logError } from "./log";
|
|
|
62
62
|
import {
|
|
63
63
|
type FixedModelRoute,
|
|
64
64
|
type InferenceTier,
|
|
65
|
+
inferenceEffort,
|
|
65
66
|
inferenceModel,
|
|
66
67
|
isFixedModelRoute,
|
|
67
68
|
} from "./models";
|
|
@@ -224,10 +225,21 @@ export function plannedInferenceRoute(
|
|
|
224
225
|
return { agent: previewRoute().agent, ...plannedRoute({ user: "", tier, caller }) };
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
function routeWithModel(
|
|
229
|
+
opts: InferenceOptions
|
|
230
|
+
): ReturnType<typeof plannedRoute> | { error: string } {
|
|
231
|
+
try {
|
|
232
|
+
return plannedRoute(opts);
|
|
233
|
+
} catch (err) {
|
|
234
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
227
238
|
export async function inference(opts: InferenceOptions): Promise<InferenceResult> {
|
|
239
|
+
const planned = routeWithModel(opts);
|
|
240
|
+
if ("error" in planned) return { success: false, error: planned.error };
|
|
228
241
|
const started = performance.now();
|
|
229
242
|
const result = await checkedInference(opts);
|
|
230
|
-
const planned = plannedRoute(opts);
|
|
231
243
|
logInferenceCall(opts, planned, result, performance.now() - started);
|
|
232
244
|
return { ...result, model: planned.model ?? undefined };
|
|
233
245
|
}
|
|
@@ -524,9 +536,15 @@ export function buildCodexArgs(opts: InferenceOptions): string[] {
|
|
|
524
536
|
"--ephemeral",
|
|
525
537
|
"--json",
|
|
526
538
|
...(opts.web ? ["-c", "web_search=live"] : []),
|
|
539
|
+
...codexEffortArgs(opts),
|
|
527
540
|
];
|
|
528
541
|
}
|
|
529
542
|
|
|
543
|
+
function codexEffortArgs(opts: InferenceOptions): string[] {
|
|
544
|
+
const effort = inferenceEffort("codex-spawn", opts.tier);
|
|
545
|
+
return effort ? ["-c", `model_reasoning_effort="${effort}"`] : [];
|
|
546
|
+
}
|
|
547
|
+
|
|
530
548
|
interface CodexItem {
|
|
531
549
|
type?: string;
|
|
532
550
|
text?: string;
|
|
@@ -1124,31 +1142,38 @@ async function inferenceViaApi(opts: InferenceOptions): Promise<InferenceResult>
|
|
|
1124
1142
|
// structured-output schema for JSON-mode callers.
|
|
1125
1143
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1126
1144
|
|
|
1145
|
+
export function buildOpenAiBody(opts: InferenceOptions): Record<string, unknown> {
|
|
1146
|
+
const { system, user, maxTokens = 500, jsonSchema } = opts;
|
|
1147
|
+
const messages: Array<{ role: string; content: string }> = [];
|
|
1148
|
+
if (system) messages.push({ role: "system", content: system });
|
|
1149
|
+
messages.push({ role: "user", content: user });
|
|
1150
|
+
|
|
1151
|
+
const body: Record<string, unknown> = {
|
|
1152
|
+
model: modelFor("openai-api", opts),
|
|
1153
|
+
max_tokens: maxTokens,
|
|
1154
|
+
messages,
|
|
1155
|
+
};
|
|
1156
|
+
const effort = inferenceEffort("openai-api", opts.tier);
|
|
1157
|
+
if (effort) body.reasoning_effort = effort;
|
|
1158
|
+
if (jsonSchema) {
|
|
1159
|
+
body.response_format = {
|
|
1160
|
+
type: "json_schema",
|
|
1161
|
+
json_schema: { name: "structured_response", strict: true, schema: jsonSchema },
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
return body;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1127
1167
|
async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise<InferenceResult> {
|
|
1128
1168
|
const apiKey = withPalEnv().PAL_OPENAI_API_KEY;
|
|
1129
1169
|
if (!apiKey) return { success: false };
|
|
1130
1170
|
|
|
1131
|
-
const {
|
|
1171
|
+
const { timeout = 15000 } = opts;
|
|
1132
1172
|
|
|
1133
1173
|
try {
|
|
1134
1174
|
const controller = new AbortController();
|
|
1135
1175
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
1136
|
-
|
|
1137
|
-
const messages: Array<{ role: string; content: string }> = [];
|
|
1138
|
-
if (system) messages.push({ role: "system", content: system });
|
|
1139
|
-
messages.push({ role: "user", content: user });
|
|
1140
|
-
|
|
1141
|
-
const body: Record<string, unknown> = {
|
|
1142
|
-
model: modelFor("openai-api", opts),
|
|
1143
|
-
max_tokens: maxTokens,
|
|
1144
|
-
messages,
|
|
1145
|
-
};
|
|
1146
|
-
if (jsonSchema) {
|
|
1147
|
-
body.response_format = {
|
|
1148
|
-
type: "json_schema",
|
|
1149
|
-
json_schema: { name: "structured_response", strict: true, schema: jsonSchema },
|
|
1150
|
-
};
|
|
1151
|
-
}
|
|
1176
|
+
const body = buildOpenAiBody(opts);
|
|
1152
1177
|
|
|
1153
1178
|
const response = await fetch("https://api.openai.com/v1/chat/completions", {
|
|
1154
1179
|
method: "POST",
|
package/src/hooks/lib/models.ts
CHANGED
|
@@ -6,11 +6,13 @@ import type { AgentType } from "./agent";
|
|
|
6
6
|
|
|
7
7
|
export const HAIKU_MODEL = "claude-haiku-4-5-20251001";
|
|
8
8
|
export const HAIKU_5_5_MODEL = "claude-haiku-5-5";
|
|
9
|
-
export const SONNET_MODEL = "claude-sonnet-5";
|
|
10
|
-
export const
|
|
9
|
+
export const SONNET_MODEL = "claude-sonnet-5-5";
|
|
10
|
+
export const OPUS_MODEL = "claude-opus-5-5";
|
|
11
|
+
export const FABLE_MODEL = "claude-fable-5-1";
|
|
11
12
|
|
|
12
|
-
export const INFERENCE_TIERS = ["small", "medium"] as const;
|
|
13
|
+
export const INFERENCE_TIERS = ["small", "medium", "large", "flagship"] as const;
|
|
13
14
|
export type InferenceTier = (typeof INFERENCE_TIERS)[number];
|
|
15
|
+
type ReasoningEffort = "low" | "medium" | "high";
|
|
14
16
|
|
|
15
17
|
export type FixedModelRoute =
|
|
16
18
|
| "claude-spawn"
|
|
@@ -19,24 +21,37 @@ export type FixedModelRoute =
|
|
|
19
21
|
| "openai-api"
|
|
20
22
|
| "antigravity-spawn";
|
|
21
23
|
|
|
22
|
-
|
|
23
|
-
type RouteModels = Record<InferenceTier, string> & { large?: string };
|
|
24
|
+
type RouteModels = Record<"small" | "medium" | "large", string> & { flagship?: string };
|
|
24
25
|
|
|
25
26
|
const ANTHROPIC_MODELS: RouteModels = {
|
|
26
27
|
small: HAIKU_MODEL,
|
|
27
28
|
medium: SONNET_MODEL,
|
|
28
|
-
large:
|
|
29
|
+
large: OPUS_MODEL,
|
|
30
|
+
flagship: FABLE_MODEL,
|
|
29
31
|
};
|
|
30
32
|
const OPENAI_MODELS: RouteModels = {
|
|
31
33
|
small: "gpt-6-luna",
|
|
32
34
|
medium: "gpt-6-sol",
|
|
33
|
-
large: "gpt-6-
|
|
35
|
+
large: "gpt-6-sol",
|
|
36
|
+
flagship: "gpt-6-astra",
|
|
34
37
|
};
|
|
35
|
-
/** An Antigravity subagent's `model:` takes only a tier name, so `large` is `pro`. */
|
|
36
38
|
const GEMINI_MODELS: RouteModels = {
|
|
37
39
|
small: "gemini-3.8-flash-low",
|
|
38
|
-
medium: "gemini-3.8-flash-
|
|
39
|
-
large: "
|
|
40
|
+
medium: "gemini-3.8-flash-medium",
|
|
41
|
+
large: "gemini-3.8-flash-high",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const OPENAI_EFFORTS: Partial<Record<InferenceTier, ReasoningEffort>> = { large: "high" };
|
|
45
|
+
const INFERENCE_EFFORTS: Partial<
|
|
46
|
+
Record<FixedModelRoute, Partial<Record<InferenceTier, ReasoningEffort>>>
|
|
47
|
+
> = {
|
|
48
|
+
"codex-spawn": OPENAI_EFFORTS,
|
|
49
|
+
"openai-api": OPENAI_EFFORTS,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/** An Antigravity subagent's `model:` takes only a tier name, never a model id. */
|
|
53
|
+
const SUBAGENT_FLAGSHIP: Partial<Record<FixedModelRoute, string>> = {
|
|
54
|
+
"antigravity-spawn": "pro",
|
|
40
55
|
};
|
|
41
56
|
|
|
42
57
|
/**
|
|
@@ -56,12 +71,20 @@ export function isFixedModelRoute(route: string): route is FixedModelRoute {
|
|
|
56
71
|
return Object.hasOwn(INFERENCE_MODELS, route);
|
|
57
72
|
}
|
|
58
73
|
|
|
59
|
-
/**
|
|
60
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Callers on Haiku 5.5: those whose prompt passed its eval there, and every new
|
|
76
|
+
* caller, whose eval is iterated on 5.5 until it passes. The rest stay on
|
|
77
|
+
* HAIKU_MODEL until theirs is evaluated.
|
|
78
|
+
*/
|
|
79
|
+
const ANTHROPIC_ON_HAIKU_5_5 = new Set(["rule-drafter", "correction-kind"]);
|
|
61
80
|
|
|
62
|
-
function evaluatedModel(
|
|
63
|
-
|
|
64
|
-
|
|
81
|
+
function evaluatedModel(
|
|
82
|
+
models: RouteModels,
|
|
83
|
+
tier: InferenceTier,
|
|
84
|
+
caller?: string
|
|
85
|
+
): string | undefined {
|
|
86
|
+
const onAnthropicSmall = models === ANTHROPIC_MODELS && tier === "small";
|
|
87
|
+
return onAnthropicSmall && caller && ANTHROPIC_ON_HAIKU_5_5.has(caller)
|
|
65
88
|
? HAIKU_5_5_MODEL
|
|
66
89
|
: undefined;
|
|
67
90
|
}
|
|
@@ -72,7 +95,17 @@ export function inferenceModel(
|
|
|
72
95
|
caller?: string
|
|
73
96
|
): string {
|
|
74
97
|
const models = INFERENCE_MODELS[route];
|
|
75
|
-
|
|
98
|
+
const model = evaluatedModel(models, tier, caller) ?? models[tier];
|
|
99
|
+
if (!model) throw new Error(`${route} has no ${tier} model`);
|
|
100
|
+
return model;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The reasoning effort a tier asks of a model it shares with another tier; undefined keeps the model's default. */
|
|
104
|
+
export function inferenceEffort(
|
|
105
|
+
route: FixedModelRoute,
|
|
106
|
+
tier: InferenceTier = "small"
|
|
107
|
+
): ReasoningEffort | undefined {
|
|
108
|
+
return INFERENCE_EFFORTS[route]?.[tier];
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
const AGENT_SPAWN_ROUTE: Partial<Record<AgentType, FixedModelRoute>> = {
|
|
@@ -84,11 +117,13 @@ const AGENT_SPAWN_ROUTE: Partial<Record<AgentType, FixedModelRoute>> = {
|
|
|
84
117
|
/**
|
|
85
118
|
* The model an agent's `skill-author` / `subagent-author` runs on, or undefined
|
|
86
119
|
* when `create-skill` and `create-subagent` should author inline. A new provider
|
|
87
|
-
* needs a
|
|
120
|
+
* needs a flagship model on its route and a platform block in assets/agents/*-author.md.
|
|
88
121
|
*/
|
|
89
122
|
export function flagshipAuthorModel(agent: AgentType): string | undefined {
|
|
90
123
|
const route = AGENT_SPAWN_ROUTE[agent];
|
|
91
|
-
return route
|
|
124
|
+
return route
|
|
125
|
+
? (SUBAGENT_FLAGSHIP[route] ?? INFERENCE_MODELS[route].flagship)
|
|
126
|
+
: undefined;
|
|
92
127
|
}
|
|
93
128
|
|
|
94
129
|
export interface ModelPricing {
|
|
@@ -127,18 +162,31 @@ const withoutWritePremium = (
|
|
|
127
162
|
cacheRead,
|
|
128
163
|
});
|
|
129
164
|
|
|
165
|
+
const withWritePremium = (
|
|
166
|
+
input: number,
|
|
167
|
+
cacheRead: number,
|
|
168
|
+
output: number
|
|
169
|
+
): ModelPricing => ({
|
|
170
|
+
input,
|
|
171
|
+
output,
|
|
172
|
+
cacheWrite5m: input * 1.25,
|
|
173
|
+
cacheWrite1h: input * 1.25,
|
|
174
|
+
cacheRead,
|
|
175
|
+
});
|
|
176
|
+
|
|
130
177
|
/**
|
|
131
178
|
* Standard-tier rates from https://developers.openai.com/api/docs/pricing; prompts over
|
|
132
|
-
* 272K tokens cost more, and PAL's never get there.
|
|
179
|
+
* 272K tokens cost more, and PAL's never get there. GPT-6 and GPT-5.6 bill cache writes
|
|
180
|
+
* at 1.25x input; older models publish no cache-write price, so writes bill as input.
|
|
133
181
|
*/
|
|
134
182
|
const OPENAI_PRICING: Record<string, ModelPricing> = {
|
|
135
|
-
"gpt-6-astra":
|
|
136
|
-
"gpt-6.1-sol":
|
|
137
|
-
"gpt-6-sol":
|
|
138
|
-
"gpt-6-luna":
|
|
139
|
-
"gpt-5.6-sol":
|
|
140
|
-
"gpt-5.6-terra":
|
|
141
|
-
"gpt-5.6-luna":
|
|
183
|
+
"gpt-6-astra": withWritePremium(10, 1, 50),
|
|
184
|
+
"gpt-6.1-sol": withWritePremium(2, 0.1, 10),
|
|
185
|
+
"gpt-6-sol": withWritePremium(2, 0.2, 10),
|
|
186
|
+
"gpt-6-luna": withWritePremium(0.1, 0.01, 0.5),
|
|
187
|
+
"gpt-5.6-sol": withWritePremium(4, 0.4, 20),
|
|
188
|
+
"gpt-5.6-terra": withWritePremium(2, 0.2, 12),
|
|
189
|
+
"gpt-5.6-luna": withWritePremium(0.2, 0.02, 1.2),
|
|
142
190
|
"gpt-5.5": withoutWritePremium(5, 0.5, 30),
|
|
143
191
|
"gpt-5.5-pro": withoutWritePremium(30, 30, 180),
|
|
144
192
|
"gpt-5.4": withoutWritePremium(2.5, 0.25, 15),
|
|
@@ -172,14 +220,14 @@ export const MODEL_PRICING: Record<string, ModelPricing> = {
|
|
|
172
220
|
cacheWrite1h: 0.2,
|
|
173
221
|
cacheRead: 0.01,
|
|
174
222
|
},
|
|
175
|
-
|
|
223
|
+
[FABLE_MODEL]: {
|
|
176
224
|
input: 10,
|
|
177
225
|
output: 50,
|
|
178
226
|
cacheWrite5m: 12.5,
|
|
179
227
|
cacheWrite1h: 20,
|
|
180
228
|
cacheRead: 0.25,
|
|
181
229
|
},
|
|
182
|
-
|
|
230
|
+
"claude-fable-5": {
|
|
183
231
|
input: 10,
|
|
184
232
|
output: 50,
|
|
185
233
|
cacheWrite5m: 12.5,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a draft may be kept as an opinion. Opinions steer every session, so
|
|
3
|
+
* one is offered only once its kind has recurred enough to be measured, and
|
|
4
|
+
* only while no opinion or undecided draft already targets that kind: a second
|
|
5
|
+
* one would share its rate.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type AdaptationRule, isOpinion, type OpinionGate } from "./adaptation-rules";
|
|
9
|
+
import type { Turn } from "./adaptation-turns";
|
|
10
|
+
import { hasCurrentKind } from "./correction-kind";
|
|
11
|
+
import { type OpinionEffect, opinionEffectOf } from "./correction-rates";
|
|
12
|
+
|
|
13
|
+
const MIN_KIND_CORRECTIONS = 5;
|
|
14
|
+
|
|
15
|
+
function correctionsOfKind(kind: string, turns: Turn[]): number {
|
|
16
|
+
return turns.filter(
|
|
17
|
+
(turn) =>
|
|
18
|
+
turn.confirmed === true && hasCurrentKind(turn) && turn.correctionKind.kind === kind
|
|
19
|
+
).length;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function offersOpinion(rule: AdaptationRule): boolean {
|
|
23
|
+
return rule.status === "draft" && !rule.widens && !rule.opinionGate;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function sameKind(kind: string, rules: AdaptationRule[]): AdaptationRule | undefined {
|
|
27
|
+
return rules.find((r) => r.kind === kind && (isOpinion(r) || offersOpinion(r)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function opinionGate(
|
|
31
|
+
kind: string,
|
|
32
|
+
rules: AdaptationRule[],
|
|
33
|
+
turns: Turn[]
|
|
34
|
+
): OpinionGate | undefined {
|
|
35
|
+
const taken = sameKind(kind, rules);
|
|
36
|
+
if (taken) return { duplicates: taken.id };
|
|
37
|
+
const corrections = correctionsOfKind(kind, turns);
|
|
38
|
+
return corrections < MIN_KIND_CORRECTIONS ? { corrections } : undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function opinionAdvice(id: string, effect: OpinionEffect | null): string {
|
|
42
|
+
if (effect?.verdict === "lower")
|
|
43
|
+
return `opinion ${id} lowers its kind, so this is a variant; promote ${id} to a lasting principle to free its slot`;
|
|
44
|
+
if (effect?.verdict === "not-lower")
|
|
45
|
+
return `opinion ${id} is not lowering its kind; reword ${id} instead of adding another`;
|
|
46
|
+
return `opinion ${id}'s effect is not measurable yet (${effect?.verdict ?? "no effect"}); held beside it`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function duplicateAdvice(
|
|
50
|
+
id: string,
|
|
51
|
+
rules: AdaptationRule[],
|
|
52
|
+
turns: Turn[],
|
|
53
|
+
now: Date
|
|
54
|
+
): string {
|
|
55
|
+
const target = rules.find((r) => r.id === id);
|
|
56
|
+
if (target && isOpinion(target))
|
|
57
|
+
return opinionAdvice(id, opinionEffectOf(target, rules, turns, now));
|
|
58
|
+
if (target?.status === "draft")
|
|
59
|
+
return `draft ${id} already offers an opinion for this kind; decide it first`;
|
|
60
|
+
return `${id} targeted this kind and is no longer active`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function gateAdvice(
|
|
64
|
+
rule: AdaptationRule,
|
|
65
|
+
rules: AdaptationRule[],
|
|
66
|
+
turns: Turn[],
|
|
67
|
+
now: Date = new Date()
|
|
68
|
+
): string | undefined {
|
|
69
|
+
const gate = rule.status === "draft" ? rule.opinionGate : undefined;
|
|
70
|
+
if (!gate) return undefined;
|
|
71
|
+
if ("duplicates" in gate) return duplicateAdvice(gate.duplicates, rules, turns, now);
|
|
72
|
+
return `${gate.corrections} corrections of its kind so far; an opinion needs ${MIN_KIND_CORRECTIONS}`;
|
|
73
|
+
}
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The rule drafter: after a confirmed correction, the small model decides
|
|
3
|
-
* whether it repeats an earlier one and, if so, drafts
|
|
4
|
-
* prevented them all
|
|
3
|
+
* whether it repeats an earlier one and, if so, drafts the instruction that
|
|
4
|
+
* would have prevented them all: an opinion, or a rule when its optional
|
|
5
|
+
* trigger proves.
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
8
|
import type { CandidateInput } from "./adaptation-candidates";
|
|
8
|
-
import type
|
|
9
|
+
import { isTriggerRule, type RuleTrigger } from "./adaptation-rules";
|
|
9
10
|
import { type RequestedTurn, type Turn, withRequests } from "./adaptation-turns";
|
|
11
|
+
import { hasCurrentKind } from "./correction-kind";
|
|
10
12
|
import { firesOn } from "./rule-proof";
|
|
11
13
|
|
|
12
14
|
const MIN_CITED = 2;
|
|
@@ -18,14 +20,17 @@ type Correction = RequestedTurn;
|
|
|
18
20
|
export interface KnownRule {
|
|
19
21
|
id: string;
|
|
20
22
|
when: string;
|
|
21
|
-
trigger
|
|
23
|
+
trigger?: RuleTrigger;
|
|
22
24
|
steering: string;
|
|
23
25
|
check?: string;
|
|
24
26
|
status: string;
|
|
27
|
+
delivery?: "always";
|
|
25
28
|
widens?: string;
|
|
26
29
|
}
|
|
27
30
|
|
|
28
|
-
|
|
31
|
+
type TriggeredRule = KnownRule & { trigger: RuleTrigger };
|
|
32
|
+
|
|
33
|
+
const DRAFTER_SYSTEM_PROMPT = `You turn an AI assistant's repeated mistakes into one instruction that prevents them.
|
|
29
34
|
|
|
30
35
|
You get the corrections the user made recently, numbered and oldest first. Each has the end of the assistant's reply the user corrected, the user's correcting message, what the reply got wrong, and, when known, the user's request the reply answered. The newest one just happened. Then the known rules; an approved rule shows its trigger and whether that trigger fires on the newest correction.
|
|
31
36
|
|
|
@@ -37,9 +42,9 @@ Otherwise set widens to an empty string and decide whether the newest correction
|
|
|
37
42
|
|
|
38
43
|
If it is not, or another known rule already covers it, set recurring to false, say why in reason in one sentence, leave the text fields empty and set cites to [].
|
|
39
44
|
|
|
40
|
-
If it is, draft one
|
|
41
|
-
- when: the situation, in one sentence.
|
|
42
|
-
- side and pattern:
|
|
45
|
+
If it is, draft one instruction. It steers every session unless a trigger proves it can fire only when needed:
|
|
46
|
+
- when: the situation, in one sentence, general enough to cover the next time and not tied to these corrections' topics.
|
|
47
|
+
- side and pattern: optional. Leave both empty unless the situation always shows in the same words, in the user's request (side "prompt") or at the end of the assistant's reply (side "reply"). Then give a case-insensitive JavaScript regular expression that matches every cited correction on that side, would match the next one, and does not match ordinary turns. A pattern built from the words of these corrections or of the user's correcting message is worse than none.
|
|
43
48
|
- steering: the instruction to the assistant, imperative, at most two sentences.
|
|
44
49
|
- check: how to tell from a reply whether the instruction was followed, in one sentence.
|
|
45
50
|
- cites: the numbers of the corrections this rule would have prevented, including the newest.
|
|
@@ -52,7 +57,7 @@ const DRAFTER_SCHEMA = {
|
|
|
52
57
|
widens: { type: "string" },
|
|
53
58
|
reason: { type: "string" },
|
|
54
59
|
when: { type: "string" },
|
|
55
|
-
side: { enum: ["prompt", "reply"] },
|
|
60
|
+
side: { enum: ["prompt", "reply", ""] },
|
|
56
61
|
pattern: { type: "string" },
|
|
57
62
|
steering: { type: "string" },
|
|
58
63
|
check: { type: "string" },
|
|
@@ -80,12 +85,13 @@ export function canRepeat(corrections: Correction[]): boolean {
|
|
|
80
85
|
return corrections.length >= MIN_CITED;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
|
-
function missedApprovedRules(
|
|
88
|
+
function missedApprovedRules(
|
|
89
|
+
corrections: Correction[],
|
|
90
|
+
known: KnownRule[]
|
|
91
|
+
): TriggeredRule[] {
|
|
84
92
|
const newest = corrections.at(-1);
|
|
85
93
|
if (!newest) return [];
|
|
86
|
-
return known.filter(
|
|
87
|
-
(rule) => rule.status === "approved" && !firesOn(rule.trigger, newest)
|
|
88
|
-
);
|
|
94
|
+
return known.filter(isTriggerRule).filter((rule) => !firesOn(rule.trigger, newest));
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
export function canWiden(corrections: Correction[], known: KnownRule[]): boolean {
|
|
@@ -99,9 +105,11 @@ function correctionBlock(correction: Correction, n: number, newest: boolean): st
|
|
|
99
105
|
|
|
100
106
|
function knownRuleLine(rule: KnownRule, newest: Correction | undefined): string {
|
|
101
107
|
if (rule.widens)
|
|
102
|
-
return `- draft widening of rule ${rule.widens}: ${rule.trigger
|
|
103
|
-
if (rule
|
|
104
|
-
|
|
108
|
+
return `- draft widening of rule ${rule.widens}: ${rule.trigger?.pattern}`;
|
|
109
|
+
if (!isTriggerRule(rule)) {
|
|
110
|
+
const label = rule.delivery === "always" ? `opinion ${rule.id}` : rule.status;
|
|
111
|
+
return `- ${label}: ${rule.when}. ${rule.steering}`;
|
|
112
|
+
}
|
|
105
113
|
const fires = newest && firesOn(rule.trigger, newest) ? "yes" : "no";
|
|
106
114
|
return `- approved rule ${rule.id}: ${rule.when}. ${rule.steering} Trigger on the ${rule.trigger.side}: ${rule.trigger.pattern}. Fires on the newest correction: ${fires}.`;
|
|
107
115
|
}
|
|
@@ -134,7 +142,7 @@ interface DraftOutput {
|
|
|
134
142
|
recurring: boolean;
|
|
135
143
|
widens?: string;
|
|
136
144
|
when: string;
|
|
137
|
-
side: "prompt" | "reply";
|
|
145
|
+
side: "prompt" | "reply" | "";
|
|
138
146
|
pattern: string;
|
|
139
147
|
steering: string;
|
|
140
148
|
check: string;
|
|
@@ -185,23 +193,48 @@ function citedEvidence(cites: number[], corrections: Correction[]): string[] {
|
|
|
185
193
|
});
|
|
186
194
|
}
|
|
187
195
|
|
|
196
|
+
function namedKindsOf(cited: Correction[]): string[] {
|
|
197
|
+
return cited
|
|
198
|
+
.filter(hasCurrentKind)
|
|
199
|
+
.map((c) => c.correctionKind.kind)
|
|
200
|
+
.filter((kind) => kind !== "other");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function citedKind(
|
|
204
|
+
cites: number[],
|
|
205
|
+
corrections: Correction[]
|
|
206
|
+
): string | undefined {
|
|
207
|
+
const newestFirst = namedKindsOf(cites.map((n) => corrections[n])).reverse();
|
|
208
|
+
const count = (kind: string) => newestFirst.filter((k) => k === kind).length;
|
|
209
|
+
const most = Math.max(...newestFirst.map(count));
|
|
210
|
+
return newestFirst.find((kind) => count(kind) === most);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function withKind<T extends CandidateInput>(candidate: T, kind: string | undefined): T {
|
|
214
|
+
return kind ? { ...candidate, kind } : candidate;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function optionalTrigger({ side, pattern }: DraftOutput): RuleTrigger | undefined {
|
|
218
|
+
if ((side !== "prompt" && side !== "reply") || !hasText(pattern)) return undefined;
|
|
219
|
+
const bare = bareExpression(pattern);
|
|
220
|
+
return isValidPattern(bare) ? { side, pattern: bare } : undefined;
|
|
221
|
+
}
|
|
222
|
+
|
|
188
223
|
function parseNewRule(
|
|
189
224
|
draft: DraftOutput,
|
|
190
225
|
corrections: Correction[]
|
|
191
226
|
): CandidateInput | null {
|
|
192
227
|
const cites = validCites(draft.cites, corrections.length, MIN_CITED);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const
|
|
196
|
-
if (!isValidPattern(pattern)) return null;
|
|
197
|
-
if (!hasText(draft.when, draft.steering, draft.check)) return null;
|
|
198
|
-
return {
|
|
228
|
+
if (!cites || !hasText(draft.when, draft.steering, draft.check)) return null;
|
|
229
|
+
const trigger = optionalTrigger(draft);
|
|
230
|
+
const candidate = {
|
|
199
231
|
when: draft.when.trim(),
|
|
200
|
-
trigger
|
|
232
|
+
...(trigger && { trigger }),
|
|
201
233
|
steering: draft.steering.trim(),
|
|
202
234
|
check: draft.check.trim(),
|
|
203
235
|
evidence: citedEvidence(cites, corrections),
|
|
204
236
|
};
|
|
237
|
+
return withKind(candidate, citedKind(cites, corrections));
|
|
205
238
|
}
|
|
206
239
|
|
|
207
240
|
function parseWidening(
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync, readFileSync } from "node:fs";
|
|
10
10
|
import { basename, resolve } from "node:path";
|
|
11
|
+
import { opinionsContext } from "./adaptation-rules";
|
|
11
12
|
import { type FailureEntry, readFailures } from "./learning-store";
|
|
12
|
-
import { loadOpinionContext } from "./opinions";
|
|
13
13
|
import { palHome, paths } from "./paths";
|
|
14
14
|
import { type ProjectProgress, readAllProjects, resolveProjectFromCwd } from "./projects";
|
|
15
15
|
import { readFramePrinciples } from "./wisdom";
|
|
@@ -148,7 +148,7 @@ export function getSemiStaticSources(): SemiStaticSource[] {
|
|
|
148
148
|
writesDigest: true,
|
|
149
149
|
load: () => {
|
|
150
150
|
try {
|
|
151
|
-
return
|
|
151
|
+
return opinionsContext();
|
|
152
152
|
} catch {
|
|
153
153
|
return "";
|
|
154
154
|
}
|