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.
@@ -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 { system, user, maxTokens = 500, timeout = 15000, jsonSchema } = opts;
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",
@@ -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 FABLE_MODEL = "claude-fable-5";
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
- /** `large` is the flagship that authors skills and subagents; without one, the agent authors inline. */
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: FABLE_MODEL,
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-astra",
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-high",
39
- large: "pro",
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
- /** Callers whose prompt has been evaluated on Haiku 5.5; the rest stay on HAIKU_MODEL until theirs is. */
60
- const ANTHROPIC_EVALUATED_ON_HAIKU_5_5 = new Set(["rule-drafter"]);
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(models: RouteModels, caller?: string): string | undefined {
63
- const onAnthropic = models === ANTHROPIC_MODELS;
64
- return onAnthropic && caller && ANTHROPIC_EVALUATED_ON_HAIKU_5_5.has(caller)
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
- return evaluatedModel(models, caller) ?? models[tier];
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 `large` model on its route and a platform block in assets/agents/*-author.md.
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 ? INFERENCE_MODELS[route].large : undefined;
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. OpenAI bills no cache-write premium.
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": withoutWritePremium(10, 1, 50),
136
- "gpt-6.1-sol": withoutWritePremium(2, 0.1, 10),
137
- "gpt-6-sol": withoutWritePremium(2, 0.2, 10),
138
- "gpt-6-luna": withoutWritePremium(0.1, 0.01, 0.5),
139
- "gpt-5.6-sol": withoutWritePremium(4, 0.4, 20),
140
- "gpt-5.6-terra": withoutWritePremium(2, 0.2, 12),
141
- "gpt-5.6-luna": withoutWritePremium(0.2, 0.02, 1.2),
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
- "claude-fable-5-1": {
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
- [FABLE_MODEL]: {
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 a rule that would have
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 { RuleTrigger } from "./adaptation-rules";
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: RuleTrigger;
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
- const DRAFTER_SYSTEM_PROMPT = `You turn an AI assistant's repeated mistakes into one rule that prevents them.
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 rule:
41
- - when: the situation, in one sentence.
42
- - side and pattern: a case-insensitive JavaScript regular expression for that situation. Side "reply" matches the end of the assistant's reply; side "prompt" matches the user's request. It must match the cited corrections, stay general enough to catch the next one, and not match ordinary turns.
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(corrections: Correction[], known: KnownRule[]): KnownRule[] {
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.pattern}`;
103
- if (rule.status !== "approved")
104
- return `- ${rule.status}: ${rule.when}. ${rule.steering}`;
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
- const sideOk = draft.side === "prompt" || draft.side === "reply";
194
- if (!cites || !sideOk || !hasText(draft.pattern)) return null;
195
- const pattern = bareExpression(draft.pattern);
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: { side: draft.side, pattern },
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 loadOpinionContext();
151
+ return opinionsContext();
152
152
  } catch {
153
153
  return "";
154
154
  }