portable-agent-layer 0.108.0 → 0.109.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.
@@ -56,12 +56,16 @@ export function isFixedModelRoute(route: string): route is FixedModelRoute {
56
56
  return Object.hasOwn(INFERENCE_MODELS, route);
57
57
  }
58
58
 
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"]);
59
+ /**
60
+ * Callers on Haiku 5.5: those whose prompt passed its eval there, and every new
61
+ * caller, whose eval is iterated on 5.5 until it passes. The rest stay on
62
+ * HAIKU_MODEL until theirs is evaluated.
63
+ */
64
+ const ANTHROPIC_ON_HAIKU_5_5 = new Set(["rule-drafter", "correction-kind"]);
61
65
 
62
66
  function evaluatedModel(models: RouteModels, caller?: string): string | undefined {
63
67
  const onAnthropic = models === ANTHROPIC_MODELS;
64
- return onAnthropic && caller && ANTHROPIC_EVALUATED_ON_HAIKU_5_5.has(caller)
68
+ return onAnthropic && caller && ANTHROPIC_ON_HAIKU_5_5.has(caller)
65
69
  ? HAIKU_5_5_MODEL
66
70
  : undefined;
67
71
  }
@@ -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
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * The Relationship tab: the rule drafts waiting on the user, what the approved
3
3
  * rules did, the candidates still being proven, and the last 30 days of labelled
4
- * turns. A decision goes through `decideRule`, the same rule the CLI and the
5
- * rule-review skill use.
4
+ * turns. A decision goes through `decideRule` or `retireRule`, the same rules the
5
+ * CLI and the rule-review skill use.
6
6
  */
7
7
 
8
8
  import { type Candidate, readCandidates } from "../../hooks/lib/adaptation-candidates";
@@ -10,8 +10,16 @@ import {
10
10
  type AdaptationRule,
11
11
  decideRule,
12
12
  readRules,
13
+ retireRule,
14
+ type TriggerProof,
13
15
  } from "../../hooks/lib/adaptation-rules";
14
16
  import { readTurns, type Turn } from "../../hooks/lib/adaptation-turns";
17
+ import {
18
+ EFFECT_WINDOW_DAYS,
19
+ type OpinionEffect,
20
+ opinionEffectOf,
21
+ } from "../../hooks/lib/correction-rates";
22
+ import { gateAdvice } from "../../hooks/lib/opinion-gate";
15
23
  import {
16
24
  type RuleEffect,
17
25
  readRuleEvents,
@@ -25,12 +33,21 @@ const RECENT_CORRECTIONS = 10;
25
33
 
26
34
  export interface ActiveRule extends AdaptationRule {
27
35
  effect: RuleEffect;
36
+ opinionEffect: OpinionEffect | null;
28
37
  }
29
38
 
30
- export interface PipelineCandidate extends Candidate {
31
- ordinaryNeeded: number;
39
+ export interface DraftRule extends AdaptationRule {
40
+ /** Why it cannot be kept as an opinion, and what to do instead. */
41
+ gateAdvice?: string;
32
42
  }
33
43
 
44
+ export type PageDecision = "approved" | "denied" | "opinion" | "retired";
45
+
46
+ export type PipelineCandidate = Candidate & {
47
+ proof: TriggerProof;
48
+ ordinaryNeeded: number;
49
+ };
50
+
34
51
  interface TurnStats {
35
52
  total: number;
36
53
  byReaction: Record<string, number>;
@@ -40,7 +57,7 @@ interface TurnStats {
40
57
 
41
58
  export interface RelationshipView {
42
59
  windowDays: number;
43
- drafts: AdaptationRule[];
60
+ drafts: DraftRule[];
44
61
  active: ActiveRule[];
45
62
  denied: AdaptationRule[];
46
63
  pipeline: PipelineCandidate[];
@@ -69,33 +86,41 @@ export function relationship(now: Date = new Date()): RelationshipView {
69
86
  const rules = readRules();
70
87
  const turns = readTurns(WINDOW_DAYS, now);
71
88
  const effects = ruleEffects(readRuleEvents(WINDOW_DAYS, now), turns);
89
+ const opinionTurns = readTurns(EFFECT_WINDOW_DAYS, now);
72
90
  const withStatus = (status: AdaptationRule["status"]) =>
73
91
  rules.filter((rule) => rule.status === status);
74
92
  return {
75
93
  windowDays: WINDOW_DAYS,
76
- drafts: withStatus("draft"),
94
+ drafts: withStatus("draft").map((rule) => {
95
+ const advice = gateAdvice(rule, rules, opinionTurns, now);
96
+ return advice ? { ...rule, gateAdvice: advice } : rule;
97
+ }),
77
98
  active: withStatus("approved").map((rule) => ({
78
99
  ...rule,
79
100
  effect: effects[rule.id] ?? NO_EFFECT,
101
+ opinionEffect: opinionEffectOf(rule, rules, opinionTurns, now),
80
102
  })),
81
103
  denied: withStatus("denied"),
82
- pipeline: readCandidates()
83
- .filter((candidate) => candidate.verdict !== "passed")
84
- .map((candidate) => ({
85
- ...candidate,
86
- ordinaryNeeded:
87
- candidate.verdict === "waiting" ? ordinaryTurnsNeeded(candidate.proof) : 0,
88
- })),
104
+ pipeline: readCandidates().flatMap((candidate) =>
105
+ candidate.verdict === "passed" || candidate.verdict === "untriggered"
106
+ ? []
107
+ : [
108
+ {
109
+ ...candidate,
110
+ ordinaryNeeded:
111
+ candidate.verdict === "waiting"
112
+ ? ordinaryTurnsNeeded(candidate.proof)
113
+ : 0,
114
+ },
115
+ ]
116
+ ),
89
117
  turns: turnStats(turns),
90
118
  };
91
119
  }
92
120
 
93
- export function decideFromPage(
94
- id: string,
95
- decision: "approved" | "denied"
96
- ): WriteOutcome {
121
+ export function decideFromPage(id: string, decision: PageDecision): WriteOutcome {
97
122
  const known = readRules().some((rule) => rule.id === id);
98
- const result = decideRule(id, decision);
123
+ const result = decision === "retired" ? retireRule(id) : decideRule(id, decision);
99
124
  if (result.ok) return { ok: true, changed: true };
100
125
  return { ok: false, status: known ? 409 : 404, error: result.reason };
101
126
  }
@@ -18,7 +18,7 @@ import { agenda, agentsAtWork, board, handoffs, signal, summary } from "./data";
18
18
  import { projectDetail } from "./detail";
19
19
  import { matrix } from "./matrix";
20
20
  import { type PrefUpdate, readPrefs, validatePrefs, writePrefs } from "./prefs";
21
- import { decideFromPage, relationship } from "./relationship";
21
+ import { decideFromPage, type PageDecision, relationship } from "./relationship";
22
22
  import { DEFAULT_PORT, LOOPBACK } from "./server-config";
23
23
  import { MAX_SNOOZE_DAYS, setSnooze } from "./snooze";
24
24
  import { indexHtml, staticAsset } from "./static";
@@ -290,16 +290,23 @@ async function prefsWrite(request: Request): Promise<Response> {
290
290
  return failure ? json({ error: failure }, 400) : json(writePrefs(body as PrefUpdate));
291
291
  }
292
292
 
293
- /** Approve or deny a rule draft — the same rule `pal cli rule` reaches. */
293
+ const PAGE_DECISIONS: readonly PageDecision[] = [
294
+ "approved",
295
+ "denied",
296
+ "opinion",
297
+ "retired",
298
+ ];
299
+
300
+ /** Decide a rule draft or retire a rule — the same rules `pal cli rule` reaches. */
294
301
  async function ruleWrite(request: Request): Promise<Response> {
295
302
  const body = await readBody(request);
296
303
  if (!body) return json({ error: "expected a JSON body" }, 400);
297
304
  const { id, decision } = body;
298
305
  if (typeof id !== "string" || !id) return json({ error: "id is required" }, 400);
299
- if (decision !== "approved" && decision !== "denied") {
300
- return json({ error: "decision must be approved or denied" }, 400);
306
+ if (!PAGE_DECISIONS.includes(decision as PageDecision)) {
307
+ return json({ error: `decision must be one of ${PAGE_DECISIONS.join(", ")}` }, 400);
301
308
  }
302
- return answer(decideFromPage(id, decision), { id, decision });
309
+ return answer(decideFromPage(id, decision as PageDecision), { id, decision });
303
310
  }
304
311
 
305
312
  const WRITES: Record<string, (request: Request) => Promise<Response>> = {