portable-agent-layer 0.86.2 → 0.86.3

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.86.2",
3
+ "version": "0.86.3",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,6 +10,7 @@
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
14
  import { spawnDetachedInference } from "../lib/detached-inference";
14
15
  import { canInfer, inference } from "../lib/inference";
15
16
  import { replyEnd } from "../lib/interaction-samples";
@@ -21,6 +22,7 @@ import {
21
22
  ratingContext,
22
23
  ratingFromLabels,
23
24
  reactionRequest,
25
+ turnFromLabels,
24
26
  } from "../lib/reaction-rating";
25
27
  import { emitRating } from "../lib/signals";
26
28
  import { now } from "../lib/time";
@@ -223,6 +225,12 @@ async function runReactionRatingAndStore(
223
225
  if (first && rating !== null) {
224
226
  handleRating(rating, ratingContext(first, message), "implicit", reply, message);
225
227
  }
228
+ const turn = turnFromLabels(
229
+ { session: sessionId ?? "", message, replyEnd: reply },
230
+ first,
231
+ confirmation
232
+ );
233
+ if (turn) appendTurn(turn);
226
234
  } catch (err) {
227
235
  const { logError } = await import("../lib/log");
228
236
  logError("rating:reaction-child", err);
@@ -0,0 +1,90 @@
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
+ function monthFile(date: Date): string {
34
+ return `turns-${date.toISOString().slice(0, 7)}.jsonl`;
35
+ }
36
+
37
+ function monthEnd(file: string): number | null {
38
+ const match = MONTH_FILE.exec(file);
39
+ return match ? Date.UTC(Number(match[1]), Number(match[2]), 1) : null;
40
+ }
41
+
42
+ function monthFiles(): string[] {
43
+ const dir = paths.adaptation();
44
+ return existsSync(dir)
45
+ ? readdirSync(dir)
46
+ .filter((f) => MONTH_FILE.test(f))
47
+ .sort()
48
+ : [];
49
+ }
50
+
51
+ function pruneMonthsBefore(cutoff: number): void {
52
+ for (const file of monthFiles()) {
53
+ const end = monthEnd(file);
54
+ if (end !== null && end <= cutoff)
55
+ rmSync(resolve(paths.adaptation(), file), { force: true });
56
+ }
57
+ }
58
+
59
+ export function appendTurn(turn: TurnInput, now: Date = new Date()): void {
60
+ const record: Turn = {
61
+ ts: now.toISOString(),
62
+ ...turn,
63
+ message: turn.message.slice(0, MESSAGE_MAX),
64
+ replyEnd: turn.replyEnd.slice(-REPLY_END_MAX),
65
+ };
66
+ appendFileSync(
67
+ resolve(paths.adaptation(), monthFile(now)),
68
+ `${JSON.stringify(record)}\n`
69
+ );
70
+ pruneMonthsBefore(now.getTime() - WINDOW_DAYS * DAY_MS);
71
+ }
72
+
73
+ function parseLine(line: string): Turn | null {
74
+ try {
75
+ const parsed = JSON.parse(line) as Turn;
76
+ return typeof parsed.ts === "string" ? parsed : null;
77
+ } catch {
78
+ return null;
79
+ }
80
+ }
81
+
82
+ export function readTurns(days: number = WINDOW_DAYS, now: Date = new Date()): Turn[] {
83
+ const cutoff = now.getTime() - days * DAY_MS;
84
+ return monthFiles()
85
+ .flatMap((file) =>
86
+ readFileSync(resolve(paths.adaptation(), file), "utf-8").split("\n")
87
+ )
88
+ .map(parseLine)
89
+ .filter((turn): turn is Turn => turn !== null && Date.parse(turn.ts) >= cutoff);
90
+ }
@@ -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)}`;