portable-agent-layer 0.86.3 → 0.88.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 +9 -0
  3. package/assets/templates/pal-settings.json +1 -0
  4. package/package.json +1 -1
  5. package/src/cli/rule.ts +14 -1
  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 +51 -7
  10. package/src/hooks/lib/adaptation-candidates.ts +95 -0
  11. package/src/hooks/lib/adaptation-rules.ts +36 -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 +240 -0
  16. package/src/hooks/lib/rule-effect.ts +87 -0
  17. package/src/hooks/lib/rule-proof.ts +65 -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-BZMDzWFa.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 +241 -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
  },
@@ -36,8 +36,11 @@ Each draft prints as:
36
36
 
37
37
  ```
38
38
  <id> [draft] <situation>
39
+ widens: <id of an approved rule> (only on a widening)
39
40
  trigger (<prompt|reply>): <pattern>
40
41
  steering: <steering>
42
+ check: <how to tell the steering was followed>
43
+ proof: fired on <n>/<n> corrections, <n>/<n> ordinary turns
41
44
  evidence: <line>
42
45
  ```
43
46
 
@@ -83,10 +86,16 @@ Present each draft as one compact block, nothing else in between:
83
86
  Draft <id>
84
87
  When: <situation>
85
88
  Steer: <steering>
89
+ Check: <check>
90
+ Proof: fired on <n>/<n> corrections, <n>/<n> ordinary turns
86
91
  Evidence: <evidence line>
87
92
  <evidence line>
88
93
  ```
89
94
 
95
+ 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.
96
+
97
+ A draft with a `widens:` line is not a new rule. An approved rule missed a correction because its trigger did not match the user's wording, and the draft is that rule with a wider trigger. Add a line `Widens: rule <id>; approving replaces its trigger with the one above` under When. Approving it updates the approved rule and the draft disappears; the CLI then reports `Rule <id> now triggers on: <pattern> (widened by <draft id>)`. Report that line verbatim too. Denying it leaves the approved rule as it was.
98
+
90
99
  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
100
 
92
101
  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.88.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">> = {
@@ -45,12 +46,24 @@ function cmdList(args: string[]): number {
45
46
  function formatRule(rule: AdaptationRule): string {
46
47
  return [
47
48
  `${rule.id} [${rule.status}] ${rule.when}`,
49
+ ...(rule.widens ? [` widens: ${rule.widens}`] : []),
48
50
  ` trigger (${rule.trigger.side}): ${rule.trigger.pattern}`,
49
51
  ` steering: ${rule.steering}`,
52
+ ...(rule.check ? [` check: ${rule.check}`] : []),
53
+ ...(rule.proof ? [` proof: ${formatProof(rule.proof)}`] : []),
50
54
  ...rule.evidence.map((e) => ` evidence: ${e}`),
51
55
  ].join("\n");
52
56
  }
53
57
 
58
+ function formatProof(proof: TriggerProof): string {
59
+ return `fired on ${proof.firedCorrections}/${proof.corrections} corrections, ${proof.firedOrdinary}/${proof.ordinary} ordinary turns`;
60
+ }
61
+
62
+ function decisionLine(id: string, decision: string, rule: AdaptationRule): string {
63
+ if (rule.id === id) return `Rule ${id} ${decision}: ${rule.when}`;
64
+ return `Rule ${rule.id} now triggers on: ${rule.trigger.pattern} (widened by ${id})`;
65
+ }
66
+
54
67
  function cmdDecide(
55
68
  id: string | undefined,
56
69
  decision: Exclude<RuleStatus, "draft">
@@ -64,7 +77,7 @@ function cmdDecide(
64
77
  console.error(result.reason);
65
78
  return 1;
66
79
  }
67
- console.log(`Rule ${id} ${decision}: ${result.rule.when}`);
80
+ console.log(decisionLine(id, decision, result.rule));
68
81
  return 0;
69
82
  }
70
83
 
@@ -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,13 @@ import {
24
31
  reactionRequest,
25
32
  turnFromLabels,
26
33
  } from "../lib/reaction-rating";
34
+ import {
35
+ canRepeat,
36
+ canWiden,
37
+ correctionsToDraftFrom,
38
+ drafterRequest,
39
+ parseDraft,
40
+ } from "../lib/rule-drafter";
27
41
  import { emitRating } from "../lib/signals";
28
42
  import { now } from "../lib/time";
29
43
  import { logTokenUsage } from "../lib/token-usage";
@@ -173,7 +187,7 @@ function handleRating(
173
187
 
174
188
  // ── Implicit Rating ──
175
189
 
176
- function handleImplicitReaction(message: string, sessionId?: string): void {
190
+ function handleImplicitReaction(message: string, sentAt: Date, sessionId?: string): void {
177
191
  const trimmed = message.trim();
178
192
  const reply = replyEnd(getLastResponse(sessionId));
179
193
 
@@ -199,6 +213,7 @@ function handleImplicitReaction(message: string, sessionId?: string): void {
199
213
  sessionId ?? "",
200
214
  Buffer.from(trimmed).toString("base64"),
201
215
  Buffer.from(reply).toString("base64"),
216
+ sentAt.toISOString(),
202
217
  ],
203
218
  "rating"
204
219
  );
@@ -210,11 +225,33 @@ async function labelReaction(reply: string, message: string, sessionId?: string)
210
225
  return result.success ? parseReactionLabel(result.output) : null;
211
226
  }
212
227
 
228
+ function knownRules() {
229
+ const waiting = readCandidates().filter((candidate) => candidate.verdict === "waiting");
230
+ return [
231
+ ...readRules(),
232
+ ...waiting.map((candidate) => ({ ...candidate, status: "candidate" })),
233
+ ];
234
+ }
235
+
236
+ async function draftRuleCandidate(sessionId?: string): Promise<void> {
237
+ const turns = withRequests(readTurns());
238
+ reproveWaiting(turns);
239
+ const corrections = correctionsToDraftFrom(turns);
240
+ const known = knownRules();
241
+ if (!canRepeat(corrections) && !canWiden(corrections, known)) return;
242
+ const result = await inference(drafterRequest(corrections, known, sessionId));
243
+ if (result.usage) logTokenUsage("rule-drafter", result.usage);
244
+ const candidate = result.success ? parseDraft(result.output, corrections, known) : null;
245
+ if (candidate) recordCandidate(candidate, turns);
246
+ else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
247
+ }
248
+
213
249
  /** Background mode: label the reaction, confirm a correction, store the rating. */
214
250
  async function runReactionRatingAndStore(
215
251
  message: string,
216
252
  reply: string,
217
- sessionId?: string
253
+ sessionId: string | undefined,
254
+ sentAt: Date
218
255
  ): Promise<void> {
219
256
  try {
220
257
  const first = await labelReaction(reply, message, sessionId);
@@ -230,7 +267,8 @@ async function runReactionRatingAndStore(
230
267
  first,
231
268
  confirmation
232
269
  );
233
- if (turn) appendTurn(turn);
270
+ if (turn) appendTurn(turn, sentAt);
271
+ if (turn?.confirmed) await draftRuleCandidate(sessionId);
234
272
  } catch (err) {
235
273
  const { logError } = await import("../lib/log");
236
274
  logError("rating:reaction-child", err);
@@ -239,7 +277,11 @@ async function runReactionRatingAndStore(
239
277
 
240
278
  // ── Main Export ──
241
279
 
242
- export function captureRating(message: string, sessionId?: string): void {
280
+ export function captureRating(
281
+ message: string,
282
+ sessionId?: string,
283
+ sentAt: Date = new Date()
284
+ ): void {
243
285
  // Strip IDE/system-injected tags to recover raw user text
244
286
  const cleaned = stripInjectedTags(message);
245
287
 
@@ -258,7 +300,7 @@ export function captureRating(message: string, sessionId?: string): void {
258
300
 
259
301
  // Path 2: Implicit reaction — the praise fast-path runs synchronously, the
260
302
  // model path detaches to a background bun subprocess (mirrors session-name).
261
- handleImplicitReaction(cleaned, sessionId);
303
+ handleImplicitReaction(cleaned, sentAt, sessionId);
262
304
  }
263
305
 
264
306
  // Background reaction entry point
@@ -268,10 +310,12 @@ if (process.argv[2] === "--sentiment") {
268
310
  const replyB64 = process.argv[5];
269
311
  if (msgB64 && replyB64) {
270
312
  const decode = (b64: string) => Buffer.from(b64, "base64").toString("utf-8");
313
+ const sentAt = new Date(process.argv[6] ?? "");
271
314
  await runReactionRatingAndStore(
272
315
  decode(msgB64),
273
316
  decode(replyB64),
274
- sid === "" ? undefined : sid
317
+ sid === "" ? undefined : sid,
318
+ Number.isNaN(sentAt.getTime()) ? new Date() : sentAt
275
319
  );
276
320
  }
277
321
  process.exit(0);
@@ -0,0 +1,95 @@
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 {
15
+ type ProofVerdict,
16
+ proofVerdict,
17
+ proveTrigger,
18
+ widenedProofVerdict,
19
+ } from "./rule-proof";
20
+
21
+ export interface CandidateInput extends DraftInput {
22
+ check: string;
23
+ }
24
+
25
+ export interface Candidate extends CandidateInput {
26
+ id: string;
27
+ createdAt: string;
28
+ verdict: ProofVerdict;
29
+ proof: TriggerProof;
30
+ }
31
+
32
+ function candidatesPath(): string {
33
+ return resolve(paths.adaptation(), "candidates.jsonl");
34
+ }
35
+
36
+ function parseLine(line: string): Candidate | null {
37
+ try {
38
+ const parsed = JSON.parse(line) as Candidate;
39
+ return typeof parsed.createdAt === "string" ? parsed : null;
40
+ } catch {
41
+ return null;
42
+ }
43
+ }
44
+
45
+ export function readCandidates(): Candidate[] {
46
+ const path = candidatesPath();
47
+ if (!existsSync(path)) return [];
48
+ return readFileSync(path, "utf-8")
49
+ .split("\n")
50
+ .map(parseLine)
51
+ .filter((candidate): candidate is Candidate => candidate !== null);
52
+ }
53
+
54
+ function writeCandidates(candidates: Candidate[]): void {
55
+ const path = candidatesPath();
56
+ const pending = `${path}.${process.pid}.tmp`;
57
+ writeFileSync(pending, candidates.map((c) => `${JSON.stringify(c)}\n`).join(""));
58
+ renameSync(pending, path);
59
+ }
60
+
61
+ function prove(candidate: Candidate, turns: RequestedTurn[]): Candidate {
62
+ const proof = proveTrigger(candidate.trigger, turns);
63
+ const verdict = candidate.widens ? widenedProofVerdict(proof) : proofVerdict(proof);
64
+ return { ...candidate, proof, verdict };
65
+ }
66
+
67
+ function promoteIfPassed(candidate: Candidate): void {
68
+ if (candidate.verdict !== "passed") return;
69
+ const { id: _id, createdAt: _createdAt, verdict: _verdict, ...draft } = candidate;
70
+ addDraft(draft);
71
+ }
72
+
73
+ export function recordCandidate(
74
+ input: CandidateInput,
75
+ turns: RequestedTurn[],
76
+ now: Date = new Date()
77
+ ): Candidate {
78
+ const fresh = {
79
+ ...input,
80
+ id: randomUUID().slice(0, 8),
81
+ createdAt: now.toISOString(),
82
+ } as Candidate;
83
+ const candidate = prove(fresh, turns);
84
+ writeCandidates([...readCandidates(), candidate]);
85
+ promoteIfPassed(candidate);
86
+ return candidate;
87
+ }
88
+
89
+ export function reproveWaiting(turns: RequestedTurn[]): void {
90
+ const candidates = readCandidates();
91
+ if (!candidates.some((c) => c.verdict === "waiting")) return;
92
+ const updated = candidates.map((c) => (c.verdict === "waiting" ? prove(c, turns) : c));
93
+ writeCandidates(updated);
94
+ updated.filter((c, i) => candidates[i].verdict === "waiting").forEach(promoteIfPassed);
95
+ }
@@ -12,17 +12,30 @@ 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;
37
+ /** The approved rule whose trigger this draft replaces with a wider one. */
38
+ widens?: string;
26
39
  }
27
40
 
28
41
  export interface AdaptationRule extends DraftInput {
@@ -67,12 +80,34 @@ export function addDraft(input: DraftInput): AdaptationRule {
67
80
  return rule;
68
81
  }
69
82
 
83
+ function approveWidening(
84
+ rules: AdaptationRule[],
85
+ widening: AdaptationRule
86
+ ): DecideResult {
87
+ const target = rules.find((r) => r.id === widening.widens);
88
+ if (target?.status !== "approved")
89
+ return { ok: false, reason: `Rule ${widening.widens} is not an approved rule` };
90
+ const widened: AdaptationRule = {
91
+ ...target,
92
+ trigger: widening.trigger,
93
+ evidence: [...target.evidence, ...widening.evidence],
94
+ proof: widening.proof,
95
+ };
96
+ writeRules(
97
+ rules
98
+ .filter((r) => r.id !== widening.id)
99
+ .map((r) => (r.id === target.id ? widened : r))
100
+ );
101
+ return { ok: true, rule: widened };
102
+ }
103
+
70
104
  export function decideRule(id: string, decision: Decision): DecideResult {
71
105
  const rules = readRules();
72
106
  const rule = rules.find((r) => r.id === id);
73
107
  if (!rule) return { ok: false, reason: `No rule with id ${id}` };
74
108
  if (rule.status !== "draft")
75
109
  return { ok: false, reason: `Rule ${id} was already ${rule.status}` };
110
+ if (rule.widens && decision === "approved") return approveWidening(rules, rule);
76
111
  const decided: AdaptationRule = {
77
112
  ...rule,
78
113
  status: decision,
@@ -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