portable-agent-layer 0.86.2 → 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 (33) 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 +55 -5
  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 +110 -0
  14. package/src/hooks/lib/claim-log.ts +1 -1
  15. package/src/hooks/lib/reaction-rating.ts +20 -0
  16. package/src/hooks/lib/rule-drafter.ts +168 -0
  17. package/src/hooks/lib/rule-effect.ts +87 -0
  18. package/src/hooks/lib/rule-proof.ts +53 -0
  19. package/src/hooks/lib/token-usage.ts +1 -0
  20. package/src/targets/opencode/plugin.ts +2 -1
  21. package/src/tools/control-room/attention.ts +18 -0
  22. package/src/tools/control-room/prefs.ts +2 -2
  23. package/src/tools/control-room/relationship.ts +101 -0
  24. package/src/tools/control-room/server.ts +24 -1
  25. package/src/tools/control-room/ui/app.tsx +20 -0
  26. package/src/tools/control-room/ui/dist/assets/index-Dd5J8No5.js +49 -0
  27. package/src/tools/control-room/ui/dist/assets/index-DeglMbfS.css +2 -0
  28. package/src/tools/control-room/ui/dist/index.html +2 -2
  29. package/src/tools/control-room/ui/lib/write.ts +4 -0
  30. package/src/tools/control-room/ui/screens/relationship.tsx +224 -0
  31. package/src/tools/control-room/ui/screens/settings.tsx +1 -0
  32. package/src/tools/control-room/ui/dist/assets/index-D_o9Usd_.js +0 -49
  33. 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.2",
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,9 +10,17 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
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";
13
20
  import { spawnDetachedInference } from "../lib/detached-inference";
14
21
  import { canInfer, inference } from "../lib/inference";
15
22
  import { replyEnd } from "../lib/interaction-samples";
23
+ import { logDebug } from "../lib/log";
16
24
  import { paths } from "../lib/paths";
17
25
  import { isSystemText, stripInjectedTags } from "../lib/prompt-text";
18
26
  import {
@@ -21,7 +29,14 @@ import {
21
29
  ratingContext,
22
30
  ratingFromLabels,
23
31
  reactionRequest,
32
+ turnFromLabels,
24
33
  } from "../lib/reaction-rating";
34
+ import {
35
+ canRepeat,
36
+ correctionsToDraftFrom,
37
+ drafterRequest,
38
+ parseDraft,
39
+ } from "../lib/rule-drafter";
25
40
  import { emitRating } from "../lib/signals";
26
41
  import { now } from "../lib/time";
27
42
  import { logTokenUsage } from "../lib/token-usage";
@@ -171,7 +186,7 @@ function handleRating(
171
186
 
172
187
  // ── Implicit Rating ──
173
188
 
174
- function handleImplicitReaction(message: string, sessionId?: string): void {
189
+ function handleImplicitReaction(message: string, sentAt: Date, sessionId?: string): void {
175
190
  const trimmed = message.trim();
176
191
  const reply = replyEnd(getLastResponse(sessionId));
177
192
 
@@ -197,6 +212,7 @@ function handleImplicitReaction(message: string, sessionId?: string): void {
197
212
  sessionId ?? "",
198
213
  Buffer.from(trimmed).toString("base64"),
199
214
  Buffer.from(reply).toString("base64"),
215
+ sentAt.toISOString(),
200
216
  ],
201
217
  "rating"
202
218
  );
@@ -208,11 +224,32 @@ async function labelReaction(reply: string, message: string, sessionId?: string)
208
224
  return result.success ? parseReactionLabel(result.output) : null;
209
225
  }
210
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
+
211
247
  /** Background mode: label the reaction, confirm a correction, store the rating. */
212
248
  async function runReactionRatingAndStore(
213
249
  message: string,
214
250
  reply: string,
215
- sessionId?: string
251
+ sessionId: string | undefined,
252
+ sentAt: Date
216
253
  ): Promise<void> {
217
254
  try {
218
255
  const first = await labelReaction(reply, message, sessionId);
@@ -223,6 +260,13 @@ async function runReactionRatingAndStore(
223
260
  if (first && rating !== null) {
224
261
  handleRating(rating, ratingContext(first, message), "implicit", reply, message);
225
262
  }
263
+ const turn = turnFromLabels(
264
+ { session: sessionId ?? "", message, replyEnd: reply },
265
+ first,
266
+ confirmation
267
+ );
268
+ if (turn) appendTurn(turn, sentAt);
269
+ if (turn?.confirmed) await draftRuleCandidate(sessionId);
226
270
  } catch (err) {
227
271
  const { logError } = await import("../lib/log");
228
272
  logError("rating:reaction-child", err);
@@ -231,7 +275,11 @@ async function runReactionRatingAndStore(
231
275
 
232
276
  // ── Main Export ──
233
277
 
234
- 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 {
235
283
  // Strip IDE/system-injected tags to recover raw user text
236
284
  const cleaned = stripInjectedTags(message);
237
285
 
@@ -250,7 +298,7 @@ export function captureRating(message: string, sessionId?: string): void {
250
298
 
251
299
  // Path 2: Implicit reaction — the praise fast-path runs synchronously, the
252
300
  // model path detaches to a background bun subprocess (mirrors session-name).
253
- handleImplicitReaction(cleaned, sessionId);
301
+ handleImplicitReaction(cleaned, sentAt, sessionId);
254
302
  }
255
303
 
256
304
  // Background reaction entry point
@@ -260,10 +308,12 @@ if (process.argv[2] === "--sentiment") {
260
308
  const replyB64 = process.argv[5];
261
309
  if (msgB64 && replyB64) {
262
310
  const decode = (b64: string) => Buffer.from(b64, "base64").toString("utf-8");
311
+ const sentAt = new Date(process.argv[6] ?? "");
263
312
  await runReactionRatingAndStore(
264
313
  decode(msgB64),
265
314
  decode(replyB64),
266
- sid === "" ? undefined : sid
315
+ sid === "" ? undefined : sid,
316
+ Number.isNaN(sentAt.getTime()) ? new Date() : sentAt
267
317
  );
268
318
  }
269
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
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The turn log: every message the reaction rater labelled, with the end of the
3
+ * reply it answered. Rule drafts are proven by replaying their trigger over it.
4
+ *
5
+ * One append-only file per month, so parallel sessions never rewrite each
6
+ * other's lines; a month is deleted whole once it ends before the window.
7
+ */
8
+
9
+ import { appendFileSync, existsSync, readdirSync, readFileSync, rmSync } from "node:fs";
10
+ import { resolve } from "node:path";
11
+ import { paths } from "./paths";
12
+
13
+ const WINDOW_DAYS = 30;
14
+ const MESSAGE_MAX = 800;
15
+ const REPLY_END_MAX = 600;
16
+ const DAY_MS = 86_400_000;
17
+ const MONTH_FILE = /^turns-(\d{4})-(\d{2})\.jsonl$/;
18
+
19
+ export interface TurnInput {
20
+ session: string;
21
+ message: string;
22
+ replyEnd: string;
23
+ reaction: string;
24
+ issue: string;
25
+ /** Set on a correction: whether the second label agreed. */
26
+ confirmed?: boolean;
27
+ }
28
+
29
+ export interface Turn extends TurnInput {
30
+ ts: string;
31
+ }
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
+
53
+ function monthFile(date: Date): string {
54
+ return `turns-${date.toISOString().slice(0, 7)}.jsonl`;
55
+ }
56
+
57
+ function monthEnd(file: string): number | null {
58
+ const match = MONTH_FILE.exec(file);
59
+ return match ? Date.UTC(Number(match[1]), Number(match[2]), 1) : null;
60
+ }
61
+
62
+ function monthFiles(): string[] {
63
+ const dir = paths.adaptation();
64
+ return existsSync(dir)
65
+ ? readdirSync(dir)
66
+ .filter((f) => MONTH_FILE.test(f))
67
+ .sort()
68
+ : [];
69
+ }
70
+
71
+ function pruneMonthsBefore(cutoff: number): void {
72
+ for (const file of monthFiles()) {
73
+ const end = monthEnd(file);
74
+ if (end !== null && end <= cutoff)
75
+ rmSync(resolve(paths.adaptation(), file), { force: true });
76
+ }
77
+ }
78
+
79
+ export function appendTurn(turn: TurnInput, now: Date = new Date()): void {
80
+ const record: Turn = {
81
+ ts: now.toISOString(),
82
+ ...turn,
83
+ message: turn.message.slice(0, MESSAGE_MAX),
84
+ replyEnd: turn.replyEnd.slice(-REPLY_END_MAX),
85
+ };
86
+ appendFileSync(
87
+ resolve(paths.adaptation(), monthFile(now)),
88
+ `${JSON.stringify(record)}\n`
89
+ );
90
+ pruneMonthsBefore(now.getTime() - WINDOW_DAYS * DAY_MS);
91
+ }
92
+
93
+ function parseLine(line: string): Turn | null {
94
+ try {
95
+ const parsed = JSON.parse(line) as Turn;
96
+ return typeof parsed.ts === "string" ? parsed : null;
97
+ } catch {
98
+ return null;
99
+ }
100
+ }
101
+
102
+ export function readTurns(days: number = WINDOW_DAYS, now: Date = new Date()): Turn[] {
103
+ const cutoff = now.getTime() - days * DAY_MS;
104
+ return monthFiles()
105
+ .flatMap((file) =>
106
+ readFileSync(resolve(paths.adaptation(), file), "utf-8").split("\n")
107
+ )
108
+ .map(parseLine)
109
+ .filter((turn): turn is Turn => turn !== null && Date.parse(turn.ts) >= cutoff);
110
+ }
@@ -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
 
@@ -6,6 +6,8 @@
6
6
  * go-ahead, a question or news carries no rating at all.
7
7
  */
8
8
 
9
+ import type { TurnInput } from "./adaptation-turns";
10
+
9
11
  const MODEL_REACTIONS = [
10
12
  "corrected",
11
13
  "repeated",
@@ -109,6 +111,24 @@ export function ratingFromLabels(
109
111
  return RATING_BY_REACTION[first.reaction] ?? null;
110
112
  }
111
113
 
114
+ interface SeenTurn {
115
+ session: string;
116
+ message: string;
117
+ replyEnd: string;
118
+ }
119
+
120
+ export function turnFromLabels(
121
+ seen: SeenTurn,
122
+ first: ReactionLabel | null,
123
+ confirmation: ReactionLabel | null
124
+ ): TurnInput | null {
125
+ if (!first) return null;
126
+ const turn = { ...seen, reaction: first.reaction, issue: first.issue };
127
+ return isCorrectionLabel(first)
128
+ ? { ...turn, confirmed: isCorrectionLabel(confirmation) }
129
+ : turn;
130
+ }
131
+
112
132
  export function ratingContext(label: ReactionLabel, message: string): string {
113
133
  const what = label.reaction === "praised" ? "Praised the reply" : label.issue;
114
134
  return `${what || label.reaction}: ${message.slice(0, 200)}`;