portable-agent-layer 0.86.3 → 0.87.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.
Files changed (32) hide show
  1. package/assets/schema/pal-settings.schema.json +4 -0
  2. package/assets/skills/rule-review/SKILL.md +6 -0
  3. package/assets/templates/pal-settings.json +1 -0
  4. package/package.json +1 -1
  5. package/src/cli/rule.ts +7 -0
  6. package/src/hooks/StopOrchestrator.ts +10 -0
  7. package/src/hooks/UserPromptOrchestrator.ts +2 -1
  8. package/src/hooks/handlers/inject-retrieval.ts +2 -0
  9. package/src/hooks/handlers/rating.ts +49 -7
  10. package/src/hooks/lib/adaptation-candidates.ts +89 -0
  11. package/src/hooks/lib/adaptation-rules.ts +12 -1
  12. package/src/hooks/lib/adaptation-steering.ts +87 -0
  13. package/src/hooks/lib/adaptation-turns.ts +20 -0
  14. package/src/hooks/lib/claim-log.ts +1 -1
  15. package/src/hooks/lib/rule-drafter.ts +168 -0
  16. package/src/hooks/lib/rule-effect.ts +87 -0
  17. package/src/hooks/lib/rule-proof.ts +53 -0
  18. package/src/hooks/lib/token-usage.ts +1 -0
  19. package/src/targets/opencode/plugin.ts +2 -1
  20. package/src/tools/control-room/attention.ts +18 -0
  21. package/src/tools/control-room/prefs.ts +2 -2
  22. package/src/tools/control-room/relationship.ts +101 -0
  23. package/src/tools/control-room/server.ts +24 -1
  24. package/src/tools/control-room/ui/app.tsx +20 -0
  25. package/src/tools/control-room/ui/dist/assets/index-Dd5J8No5.js +49 -0
  26. package/src/tools/control-room/ui/dist/assets/index-DeglMbfS.css +2 -0
  27. package/src/tools/control-room/ui/dist/index.html +2 -2
  28. package/src/tools/control-room/ui/lib/write.ts +4 -0
  29. package/src/tools/control-room/ui/screens/relationship.tsx +224 -0
  30. package/src/tools/control-room/ui/screens/settings.tsx +1 -0
  31. package/src/tools/control-room/ui/dist/assets/index-D_o9Usd_.js +0 -49
  32. package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +0 -2
@@ -94,6 +94,10 @@
94
94
  "type": "boolean",
95
95
  "description": "Opt-in, off by default. Send a reply with an unbacked result or status claim back to the agent once, asking it to show the output or say the result is unverified. Needs claimCheck. Not on Cursor, whose stop payload carries no reply text."
96
96
  },
97
+ "adaptationRules": {
98
+ "type": "boolean",
99
+ "description": "Let the rules you approved from your past corrections steer the agent: a rule triggered by your prompt is injected before the agent answers, and a reply that matches a rule's trigger is sent back to the agent once at stop. Each firing is logged to memory/adaptation/rule-events.jsonl. Set false to switch every rule off. Reply-side rules do not run on Cursor, whose stop payload carries no reply text, or on opencode, whose plugin does not run the stop check."
100
+ },
97
101
  "steeringTestReport": { "type": "boolean", "description": "When true, the assistant notes to the user which steering self-check fired. For the dual-live test period; ships false." }
98
102
  }
99
103
  },
@@ -38,6 +38,8 @@ Each draft prints as:
38
38
  <id> [draft] <situation>
39
39
  trigger (<prompt|reply>): <pattern>
40
40
  steering: <steering>
41
+ check: <how to tell the steering was followed>
42
+ proof: fired on <n>/<n> corrections, <n>/<n> ordinary turns
41
43
  evidence: <line>
42
44
  ```
43
45
 
@@ -83,10 +85,14 @@ Present each draft as one compact block, nothing else in between:
83
85
  Draft <id>
84
86
  When: <situation>
85
87
  Steer: <steering>
88
+ Check: <check>
89
+ Proof: fired on <n>/<n> corrections, <n>/<n> ordinary turns
86
90
  Evidence: <evidence line>
87
91
  <evidence line>
88
92
  ```
89
93
 
94
+ The proof is the trigger replayed over the last 30 days of turns: how many of the user's confirmed corrections it would have caught, and how many ordinary turns it would have fired on needlessly. Omit the Check or Proof line when the listing has none.
95
+
90
96
  Follow the list with one question, e.g. "Approve or deny each? Reply with the id and your decision." After a single draft, "Approve or deny <id>?" is enough.
91
97
 
92
98
  After each decision, output the CLI's result line (or error reason) verbatim and nothing more unless the user asked a question.
@@ -32,6 +32,7 @@
32
32
  "reactionSampling": true,
33
33
  "claimCheck": true,
34
34
  "claimCheckBlocks": false,
35
+ "adaptationRules": true,
35
36
  "steeringTestReport": false
36
37
  },
37
38
  "steering": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.86.3",
3
+ "version": "0.87.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/rule.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  decideRule,
13
13
  type RuleStatus,
14
14
  readRules,
15
+ type TriggerProof,
15
16
  } from "../hooks/lib/adaptation-rules";
16
17
 
17
18
  const DECISIONS: Record<string, Exclude<RuleStatus, "draft">> = {
@@ -47,10 +48,16 @@ function formatRule(rule: AdaptationRule): string {
47
48
  `${rule.id} [${rule.status}] ${rule.when}`,
48
49
  ` trigger (${rule.trigger.side}): ${rule.trigger.pattern}`,
49
50
  ` steering: ${rule.steering}`,
51
+ ...(rule.check ? [` check: ${rule.check}`] : []),
52
+ ...(rule.proof ? [` proof: ${formatProof(rule.proof)}`] : []),
50
53
  ...rule.evidence.map((e) => ` evidence: ${e}`),
51
54
  ].join("\n");
52
55
  }
53
56
 
57
+ function formatProof(proof: TriggerProof): string {
58
+ return `fired on ${proof.firedCorrections}/${proof.corrections} corrections, ${proof.firedOrdinary}/${proof.ordinary} ordinary turns`;
59
+ }
60
+
54
61
  function cmdDecide(
55
62
  id: string | undefined,
56
63
  decision: Exclude<RuleStatus, "draft">
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import { checkReadmeSync } from "./handlers/readme-sync";
9
+ import { watchReplyRules } from "./lib/adaptation-steering";
9
10
  import { stopBlockResponse } from "./lib/agent";
10
11
  import { watchClaims } from "./lib/claim-log";
11
12
  import { duplicatesCursorHooks } from "./lib/cursor-shadow";
@@ -42,4 +43,13 @@ try {
42
43
  } catch (err) {
43
44
  logError("StopOrchestrator:claim-check", err);
44
45
  }
46
+ try {
47
+ const sendBack = watchReplyRules(payload);
48
+ if (sendBack) {
49
+ process.stdout.write(stopBlockResponse(sendBack));
50
+ process.exit(0);
51
+ }
52
+ } catch (err) {
53
+ logError("StopOrchestrator:adaptation-rules", err);
54
+ }
45
55
  await stopTurn(payload);
@@ -29,12 +29,13 @@ const input = await readStdinJSON<PromptSubmitInput>();
29
29
  logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
30
30
  if (!input?.prompt) process.exit(0);
31
31
 
32
+ const sentAt = new Date();
32
33
  const sessionId = hookSessionId(input);
33
34
  const injected = await injectPromptContext(input.prompt, sessionId);
34
35
  logPromptSnapshot(input.prompt, injected);
35
36
 
36
37
  const results = await Promise.allSettled([
37
- captureRating(input.prompt, sessionId),
38
+ captureRating(input.prompt, sessionId, sentAt),
38
39
  captureSessionName(input.prompt, sessionId ?? ""),
39
40
  ]);
40
41
 
@@ -7,6 +7,7 @@
7
7
  * produces empty output, never blocks the prompt.
8
8
  */
9
9
 
10
+ import { promptRulesReminder } from "../lib/adaptation-steering";
10
11
  import { promptContextResponse } from "../lib/agent";
11
12
  import { dueNudgeReminder } from "../lib/daily-nudge";
12
13
  import { observeTurn } from "../lib/interaction";
@@ -82,6 +83,7 @@ export async function getPromptContext(
82
83
  ): Promise<string | null> {
83
84
  const parts = [
84
85
  getWallClockReminder(),
86
+ withinBudget(() => promptRulesReminder(prompt, sessionId), BUDGET_MS),
85
87
  withinBudget(() => getRepoStateReminder(), BUDGET_MS),
86
88
  withinBudget(() => observeTurn(prompt, sessionId), BUDGET_MS),
87
89
  withinBudget(() => dueNudgeReminder(), BUDGET_MS),
@@ -10,10 +10,17 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
- import { appendTurn } from "../lib/adaptation-turns";
13
+ import {
14
+ readCandidates,
15
+ recordCandidate,
16
+ reproveWaiting,
17
+ } from "../lib/adaptation-candidates";
18
+ import { readRules } from "../lib/adaptation-rules";
19
+ import { appendTurn, readTurns, withRequests } from "../lib/adaptation-turns";
14
20
  import { spawnDetachedInference } from "../lib/detached-inference";
15
21
  import { canInfer, inference } from "../lib/inference";
16
22
  import { replyEnd } from "../lib/interaction-samples";
23
+ import { logDebug } from "../lib/log";
17
24
  import { paths } from "../lib/paths";
18
25
  import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
19
26
  import {
@@ -24,6 +31,12 @@ import {
24
31
  reactionRequest,
25
32
  turnFromLabels,
26
33
  } from "../lib/reaction-rating";
34
+ import {
35
+ canRepeat,
36
+ correctionsToDraftFrom,
37
+ drafterRequest,
38
+ parseDraft,
39
+ } from "../lib/rule-drafter";
27
40
  import { emitRating } from "../lib/signals";
28
41
  import { now } from "../lib/time";
29
42
  import { logTokenUsage } from "../lib/token-usage";
@@ -173,7 +186,7 @@ function handleRating(
173
186
 
174
187
  // ── Implicit Rating ──
175
188
 
176
- function handleImplicitReaction(message: string, sessionId?: string): void {
189
+ function handleImplicitReaction(message: string, sentAt: Date, sessionId?: string): void {
177
190
  const trimmed = message.trim();
178
191
  const reply = replyEnd(getLastResponse(sessionId));
179
192
 
@@ -199,6 +212,7 @@ function handleImplicitReaction(message: string, sessionId?: string): void {
199
212
  sessionId ?? "",
200
213
  Buffer.from(trimmed).toString("base64"),
201
214
  Buffer.from(reply).toString("base64"),
215
+ sentAt.toISOString(),
202
216
  ],
203
217
  "rating"
204
218
  );
@@ -210,11 +224,32 @@ async function labelReaction(reply: string, message: string, sessionId?: string)
210
224
  return result.success ? parseReactionLabel(result.output) : null;
211
225
  }
212
226
 
227
+ function knownRules() {
228
+ const waiting = readCandidates().filter((candidate) => candidate.verdict === "waiting");
229
+ return [
230
+ ...readRules(),
231
+ ...waiting.map((candidate) => ({ ...candidate, status: "candidate" })),
232
+ ];
233
+ }
234
+
235
+ async function draftRuleCandidate(sessionId?: string): Promise<void> {
236
+ const turns = withRequests(readTurns());
237
+ reproveWaiting(turns);
238
+ const corrections = correctionsToDraftFrom(turns);
239
+ if (!canRepeat(corrections)) return;
240
+ const result = await inference(drafterRequest(corrections, knownRules(), sessionId));
241
+ if (result.usage) logTokenUsage("rule-drafter", result.usage);
242
+ const candidate = result.success ? parseDraft(result.output, corrections) : null;
243
+ if (candidate) recordCandidate(candidate, turns);
244
+ else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
245
+ }
246
+
213
247
  /** Background mode: label the reaction, confirm a correction, store the rating. */
214
248
  async function runReactionRatingAndStore(
215
249
  message: string,
216
250
  reply: string,
217
- sessionId?: string
251
+ sessionId: string | undefined,
252
+ sentAt: Date
218
253
  ): Promise<void> {
219
254
  try {
220
255
  const first = await labelReaction(reply, message, sessionId);
@@ -230,7 +265,8 @@ async function runReactionRatingAndStore(
230
265
  first,
231
266
  confirmation
232
267
  );
233
- if (turn) appendTurn(turn);
268
+ if (turn) appendTurn(turn, sentAt);
269
+ if (turn?.confirmed) await draftRuleCandidate(sessionId);
234
270
  } catch (err) {
235
271
  const { logError } = await import("../lib/log");
236
272
  logError("rating:reaction-child", err);
@@ -239,7 +275,11 @@ async function runReactionRatingAndStore(
239
275
 
240
276
  // ── Main Export ──
241
277
 
242
- export function captureRating(message: string, sessionId?: string): void {
278
+ export function captureRating(
279
+ message: string,
280
+ sessionId?: string,
281
+ sentAt: Date = new Date()
282
+ ): void {
243
283
  // Strip IDE/system-injected tags to recover raw user text
244
284
  const cleaned = stripInjectedTags(message);
245
285
 
@@ -258,7 +298,7 @@ export function captureRating(message: string, sessionId?: string): void {
258
298
 
259
299
  // Path 2: Implicit reaction — the praise fast-path runs synchronously, the
260
300
  // model path detaches to a background bun subprocess (mirrors session-name).
261
- handleImplicitReaction(cleaned, sessionId);
301
+ handleImplicitReaction(cleaned, sentAt, sessionId);
262
302
  }
263
303
 
264
304
  // Background reaction entry point
@@ -268,10 +308,12 @@ if (process.argv[2] === "--sentiment") {
268
308
  const replyB64 = process.argv[5];
269
309
  if (msgB64 && replyB64) {
270
310
  const decode = (b64: string) => Buffer.from(b64, "base64").toString("utf-8");
311
+ const sentAt = new Date(process.argv[6] ?? "");
271
312
  await runReactionRatingAndStore(
272
313
  decode(msgB64),
273
314
  decode(replyB64),
274
- sid === "" ? undefined : sid
315
+ sid === "" ? undefined : sid,
316
+ Number.isNaN(sentAt.getTime()) ? new Date() : sentAt
275
317
  );
276
318
  }
277
319
  process.exit(0);
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Rule candidates: drafts the small model wrote from repeated corrections, each
3
+ * with the proof of its trigger. Only a proven candidate becomes a draft in the
4
+ * rule store for the user to review; one the log is still too short to judge
5
+ * waits and is proven again as the log grows.
6
+ */
7
+
8
+ import { randomUUID } from "node:crypto";
9
+ import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { addDraft, type DraftInput, type TriggerProof } from "./adaptation-rules";
12
+ import type { RequestedTurn } from "./adaptation-turns";
13
+ import { paths } from "./paths";
14
+ import { type ProofVerdict, proofVerdict, proveTrigger } from "./rule-proof";
15
+
16
+ export interface CandidateInput extends DraftInput {
17
+ check: string;
18
+ }
19
+
20
+ export interface Candidate extends CandidateInput {
21
+ id: string;
22
+ createdAt: string;
23
+ verdict: ProofVerdict;
24
+ proof: TriggerProof;
25
+ }
26
+
27
+ function candidatesPath(): string {
28
+ return resolve(paths.adaptation(), "candidates.jsonl");
29
+ }
30
+
31
+ function parseLine(line: string): Candidate | null {
32
+ try {
33
+ const parsed = JSON.parse(line) as Candidate;
34
+ return typeof parsed.createdAt === "string" ? parsed : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function readCandidates(): Candidate[] {
41
+ const path = candidatesPath();
42
+ if (!existsSync(path)) return [];
43
+ return readFileSync(path, "utf-8")
44
+ .split("\n")
45
+ .map(parseLine)
46
+ .filter((candidate): candidate is Candidate => candidate !== null);
47
+ }
48
+
49
+ function writeCandidates(candidates: Candidate[]): void {
50
+ const path = candidatesPath();
51
+ const pending = `${path}.${process.pid}.tmp`;
52
+ writeFileSync(pending, candidates.map((c) => `${JSON.stringify(c)}\n`).join(""));
53
+ renameSync(pending, path);
54
+ }
55
+
56
+ function prove(candidate: Candidate, turns: RequestedTurn[]): Candidate {
57
+ const proof = proveTrigger(candidate.trigger, turns);
58
+ return { ...candidate, proof, verdict: proofVerdict(proof) };
59
+ }
60
+
61
+ function promoteIfPassed(candidate: Candidate): void {
62
+ if (candidate.verdict !== "passed") return;
63
+ const { id: _id, createdAt: _createdAt, verdict: _verdict, ...draft } = candidate;
64
+ addDraft(draft);
65
+ }
66
+
67
+ export function recordCandidate(
68
+ input: CandidateInput,
69
+ turns: RequestedTurn[],
70
+ now: Date = new Date()
71
+ ): Candidate {
72
+ const fresh = {
73
+ ...input,
74
+ id: randomUUID().slice(0, 8),
75
+ createdAt: now.toISOString(),
76
+ } as Candidate;
77
+ const candidate = prove(fresh, turns);
78
+ writeCandidates([...readCandidates(), candidate]);
79
+ promoteIfPassed(candidate);
80
+ return candidate;
81
+ }
82
+
83
+ export function reproveWaiting(turns: RequestedTurn[]): void {
84
+ const candidates = readCandidates();
85
+ if (!candidates.some((c) => c.verdict === "waiting")) return;
86
+ const updated = candidates.map((c) => (c.verdict === "waiting" ? prove(c, turns) : c));
87
+ writeCandidates(updated);
88
+ updated.filter((c, i) => candidates[i].verdict === "waiting").forEach(promoteIfPassed);
89
+ }
@@ -12,17 +12,28 @@ import { paths } from "./paths";
12
12
  export type RuleStatus = "draft" | "approved" | "denied";
13
13
  type Decision = Exclude<RuleStatus, "draft">;
14
14
 
15
- interface RuleTrigger {
15
+ export interface RuleTrigger {
16
16
  /** Whether the pattern is matched against the user's prompt or the assistant's reply. */
17
17
  side: "prompt" | "reply";
18
18
  pattern: string;
19
19
  }
20
20
 
21
+ /** Where the trigger fired when replayed over the turn log. */
22
+ export interface TriggerProof {
23
+ firedCorrections: number;
24
+ corrections: number;
25
+ firedOrdinary: number;
26
+ ordinary: number;
27
+ }
28
+
21
29
  export interface DraftInput {
22
30
  when: string;
23
31
  trigger: RuleTrigger;
24
32
  steering: string;
25
33
  evidence: string[];
34
+ /** How to tell from a reply whether the steering was followed. */
35
+ check?: string;
36
+ proof?: TriggerProof;
26
37
  }
27
38
 
28
39
  export interface AdaptationRule extends DraftInput {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Approved adaptation rules at work: a prompt-side rule is injected when its
3
+ * trigger matches the user's prompt; a reply-side rule sends a matching reply
4
+ * back once at stop. Every fire is logged so a rule's effect can be measured.
5
+ */
6
+
7
+ import { appendFileSync } from "node:fs";
8
+ import { type AdaptationRule, type RuleTrigger, readRules } from "./adaptation-rules";
9
+ import { alreadySentBack } from "./claim-log";
10
+ import { hookFinalReply, hookSessionId } from "./hook-turn";
11
+ import { replyEnd } from "./interaction-samples";
12
+ import { ruleEventsPath } from "./rule-effect";
13
+ import { isEnabled } from "./settings";
14
+ import type { StopTurnPayload } from "./stop";
15
+
16
+ const REMINDER_MAX = 1500;
17
+ const REMINDER_HEADER =
18
+ "Rules the user approved from past corrections — follow them on this turn:";
19
+
20
+ type Side = RuleTrigger["side"];
21
+
22
+ function triggers(rule: AdaptationRule, side: Side, text: string): boolean {
23
+ if (rule.status !== "approved" || rule.trigger.side !== side) return false;
24
+ try {
25
+ return new RegExp(rule.trigger.pattern, "i").test(text);
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function firingRules(side: Side, text: string): AdaptationRule[] {
32
+ if (!isEnabled("adaptationRules") || !text) return [];
33
+ return readRules().filter((rule) => triggers(rule, side, text));
34
+ }
35
+
36
+ function logFire(
37
+ rules: AdaptationRule[],
38
+ side: Side,
39
+ session: string,
40
+ now: Date,
41
+ sentBack?: boolean
42
+ ): void {
43
+ const lines = rules.map((rule) =>
44
+ JSON.stringify({ ts: now.toISOString(), rule: rule.id, side, session, sentBack })
45
+ );
46
+ appendFileSync(ruleEventsPath(), `${lines.join("\n")}\n`);
47
+ }
48
+
49
+ function withinBudget(rules: AdaptationRule[]): AdaptationRule[] {
50
+ let size = REMINDER_HEADER.length + "<system-reminder>\n\n</system-reminder>".length;
51
+ return rules.filter((rule) => {
52
+ size += rule.steering.length + 3;
53
+ return size <= REMINDER_MAX;
54
+ });
55
+ }
56
+
57
+ export function promptRulesReminder(
58
+ prompt: string,
59
+ sessionId?: string,
60
+ now: Date = new Date()
61
+ ): string | null {
62
+ const fired = withinBudget(firingRules("prompt", prompt));
63
+ if (fired.length === 0) return null;
64
+ logFire(fired, "prompt", sessionId ?? "unknown", now);
65
+ return [
66
+ "<system-reminder>",
67
+ REMINDER_HEADER,
68
+ ...fired.map((rule) => `- ${rule.steering}`),
69
+ "</system-reminder>",
70
+ ].join("\n");
71
+ }
72
+
73
+ function sendBackReason(rules: AdaptationRule[]): string {
74
+ const steering = rules.map((rule) => rule.steering).join(" ");
75
+ return `Your reply matches a rule the user approved from past corrections. ${steering}`;
76
+ }
77
+
78
+ export function watchReplyRules(
79
+ payload: StopTurnPayload | null,
80
+ now: Date = new Date()
81
+ ): string | null {
82
+ const fired = firingRules("reply", replyEnd(hookFinalReply(payload) ?? ""));
83
+ if (!payload || fired.length === 0) return null;
84
+ const sentBack = !alreadySentBack(payload);
85
+ logFire(fired, "reply", hookSessionId(payload) ?? "unknown", now, sentBack);
86
+ return sentBack ? sendBackReason(fired) : null;
87
+ }
@@ -30,6 +30,26 @@ export interface Turn extends TurnInput {
30
30
  ts: string;
31
31
  }
32
32
 
33
+ export interface RequestedTurn extends Turn {
34
+ /** The user's request the reply answered, when that message was logged too. */
35
+ prompt: string;
36
+ }
37
+
38
+ function precedingRequest(turns: Turn[], index: number): string {
39
+ const session = turns[index].session;
40
+ for (let i = index - 1; i >= 0; i--) {
41
+ if (turns[i].session === session) return turns[i].message;
42
+ }
43
+ return "";
44
+ }
45
+
46
+ export function withRequests(turns: Turn[]): RequestedTurn[] {
47
+ return turns.map((turn, index) => ({
48
+ ...turn,
49
+ prompt: precedingRequest(turns, index),
50
+ }));
51
+ }
52
+
33
53
  function monthFile(date: Date): string {
34
54
  return `turns-${date.toISOString().slice(0, 7)}.jsonl`;
35
55
  }
@@ -72,7 +72,7 @@ function record({ check, commands, session, blocked }: Watched, now: Date) {
72
72
  appendFileSync(monthFile(now), `${JSON.stringify(line)}\n`);
73
73
  }
74
74
 
75
- function alreadySentBack(payload: StopTurnPayload): boolean {
75
+ export function alreadySentBack(payload: StopTurnPayload): boolean {
76
76
  return payload.stop_hook_active === true || (payload.loop_count ?? 0) > 0;
77
77
  }
78
78
 
@@ -0,0 +1,168 @@
1
+ /**
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.
5
+ */
6
+
7
+ import type { CandidateInput } from "./adaptation-candidates";
8
+ import { type RequestedTurn, type Turn, withRequests } from "./adaptation-turns";
9
+
10
+ const MIN_CITED = 2;
11
+ const EVIDENCE_MESSAGE_MAX = 200;
12
+
13
+ type Correction = RequestedTurn;
14
+
15
+ export interface KnownRule {
16
+ when: string;
17
+ steering: string;
18
+ status: string;
19
+ }
20
+
21
+ const DRAFTER_SYSTEM_PROMPT = `You turn an AI assistant's repeated mistakes into one rule that prevents them.
22
+
23
+ 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.
24
+
25
+ Decide whether the newest correction is the same kind of mistake as at least one earlier correction. Same kind means one instruction would have prevented both, not that they share a topic.
26
+
27
+ If it is not, or a 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 [].
28
+
29
+ If it is, draft one rule:
30
+ - when: the situation, in one sentence.
31
+ - 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.
32
+ - steering: the instruction to the assistant, imperative, at most two sentences.
33
+ - check: how to tell from a reply whether the instruction was followed, in one sentence.
34
+ - cites: the numbers of the corrections this rule would have prevented, including the newest.
35
+ - reason: why these corrections are the same mistake, in one sentence.`;
36
+
37
+ const DRAFTER_SCHEMA = {
38
+ type: "object",
39
+ properties: {
40
+ recurring: { type: "boolean" },
41
+ reason: { type: "string" },
42
+ when: { type: "string" },
43
+ side: { enum: ["prompt", "reply"] },
44
+ pattern: { type: "string" },
45
+ steering: { type: "string" },
46
+ check: { type: "string" },
47
+ cites: { type: "array", items: { type: "integer" } },
48
+ },
49
+ required: [
50
+ "recurring",
51
+ "reason",
52
+ "when",
53
+ "side",
54
+ "pattern",
55
+ "steering",
56
+ "check",
57
+ "cites",
58
+ ],
59
+ additionalProperties: false,
60
+ } as const;
61
+
62
+ export function correctionsToDraftFrom(turns: Turn[]): Correction[] {
63
+ return withRequests(turns).filter((turn) => turn.confirmed === true);
64
+ }
65
+
66
+ export function canRepeat(corrections: Correction[]): boolean {
67
+ return corrections.length >= MIN_CITED;
68
+ }
69
+
70
+ function correctionBlock(correction: Correction, n: number, newest: boolean): string {
71
+ const request = correction.prompt ? `<request>${correction.prompt}</request>\n` : "";
72
+ return `<correction n="${n}"${newest ? ' newest="true"' : ""}>\n${request}<reply_end>${correction.replyEnd}</reply_end>\n<user_message>${correction.message}</user_message>\n<what_was_wrong>${correction.issue}</what_was_wrong>\n</correction>`;
73
+ }
74
+
75
+ function knownRulesBlock(known: KnownRule[]): string {
76
+ const lines = known.map((rule) => `- ${rule.status}: ${rule.when}. ${rule.steering}`);
77
+ return `Known rules:\n${lines.length ? lines.join("\n") : "none"}`;
78
+ }
79
+
80
+ export function drafterRequest(
81
+ corrections: Correction[],
82
+ known: KnownRule[],
83
+ sessionId?: string
84
+ ) {
85
+ const last = corrections.length - 1;
86
+ const blocks = corrections.map((c, n) => correctionBlock(c, n, n === last));
87
+ return {
88
+ system: DRAFTER_SYSTEM_PROMPT,
89
+ user: `${blocks.join("\n\n")}\n\n${knownRulesBlock(known)}`,
90
+ tier: "small" as const,
91
+ maxTokens: 600,
92
+ timeout: 90_000,
93
+ jsonSchema: DRAFTER_SCHEMA,
94
+ caller: "rule-drafter",
95
+ sessionId,
96
+ };
97
+ }
98
+
99
+ interface DraftOutput {
100
+ recurring: boolean;
101
+ when: string;
102
+ side: "prompt" | "reply";
103
+ pattern: string;
104
+ steering: string;
105
+ check: string;
106
+ cites: number[];
107
+ }
108
+
109
+ const REGEX_LITERAL = /^\/(.+)\/[a-z]*$/s;
110
+
111
+ function bareExpression(pattern: string): string {
112
+ return REGEX_LITERAL.exec(pattern)?.[1] ?? pattern;
113
+ }
114
+
115
+ function isValidPattern(pattern: string): boolean {
116
+ try {
117
+ new RegExp(pattern, "i");
118
+ return pattern.trim() !== "";
119
+ } catch {
120
+ return false;
121
+ }
122
+ }
123
+
124
+ function validCites(cites: unknown, count: number): number[] | null {
125
+ if (!Array.isArray(cites)) return null;
126
+ const distinct = [...new Set(cites)];
127
+ const inRange = distinct.every((n) => Number.isInteger(n) && n >= 0 && n < count);
128
+ const citesNewest = distinct.includes(count - 1);
129
+ return inRange && citesNewest && distinct.length >= MIN_CITED
130
+ ? (distinct as number[]).sort((a, b) => a - b)
131
+ : null;
132
+ }
133
+
134
+ function parseOutput(output: string): DraftOutput | null {
135
+ try {
136
+ return JSON.parse(output) as DraftOutput;
137
+ } catch {
138
+ return null;
139
+ }
140
+ }
141
+
142
+ function hasText(...fields: unknown[]): boolean {
143
+ return fields.every((field) => typeof field === "string" && field.trim() !== "");
144
+ }
145
+
146
+ export function parseDraft(
147
+ output: string | null | undefined,
148
+ corrections: Correction[]
149
+ ): CandidateInput | null {
150
+ const draft = output ? parseOutput(output) : null;
151
+ if (draft?.recurring !== true) return null;
152
+ const cites = validCites(draft.cites, corrections.length);
153
+ const sideOk = draft.side === "prompt" || draft.side === "reply";
154
+ if (!cites || !sideOk || !hasText(draft.pattern)) return null;
155
+ const pattern = bareExpression(draft.pattern);
156
+ if (!isValidPattern(pattern)) return null;
157
+ if (!hasText(draft.when, draft.steering, draft.check)) return null;
158
+ return {
159
+ when: draft.when.trim(),
160
+ trigger: { side: draft.side, pattern },
161
+ steering: draft.steering.trim(),
162
+ check: draft.check.trim(),
163
+ evidence: cites.map((n) => {
164
+ const cited = corrections[n];
165
+ return `${cited.issue}: ${cited.message.slice(0, EVIDENCE_MESSAGE_MAX)}`;
166
+ }),
167
+ };
168
+ }