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
@@ -0,0 +1,87 @@
1
+ /**
2
+ * What an approved rule did: how often it fired, and whether the user still
3
+ * corrected the reply it steered. A turn is stamped with when its message was
4
+ * sent, so the first turn in a session after a fire judges the steered reply.
5
+ */
6
+
7
+ import { existsSync, readFileSync } from "node:fs";
8
+ import { resolve } from "node:path";
9
+ import type { RuleTrigger } from "./adaptation-rules";
10
+ import type { Turn } from "./adaptation-turns";
11
+ import { paths } from "./paths";
12
+
13
+ const WINDOW_DAYS = 30;
14
+ const DAY_MS = 86_400_000;
15
+
16
+ export interface RuleEvent {
17
+ ts: string;
18
+ rule: string;
19
+ side: RuleTrigger["side"];
20
+ session: string;
21
+ sentBack?: boolean;
22
+ }
23
+
24
+ export interface RuleEffect {
25
+ fired: number;
26
+ sentBack: number;
27
+ /** Fires followed by a labelled turn in the same session. */
28
+ judged: number;
29
+ correctedAfter: number;
30
+ }
31
+
32
+ export function ruleEventsPath(): string {
33
+ return resolve(paths.adaptation(), "rule-events.jsonl");
34
+ }
35
+
36
+ function parseLine(line: string): RuleEvent | null {
37
+ try {
38
+ const parsed = JSON.parse(line) as RuleEvent;
39
+ return typeof parsed.ts === "string" && typeof parsed.rule === "string"
40
+ ? parsed
41
+ : null;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ export function readRuleEvents(days = WINDOW_DAYS, now = new Date()): RuleEvent[] {
48
+ const path = ruleEventsPath();
49
+ if (!existsSync(path)) return [];
50
+ const cutoff = now.getTime() - days * DAY_MS;
51
+ return readFileSync(path, "utf-8")
52
+ .split("\n")
53
+ .map(parseLine)
54
+ .filter((e): e is RuleEvent => e !== null && Date.parse(e.ts) >= cutoff);
55
+ }
56
+
57
+ function turnAfter(event: RuleEvent, turns: Turn[]): Turn | undefined {
58
+ return turns.find((t) => t.session === event.session && t.ts > event.ts);
59
+ }
60
+
61
+ function isConfirmedCorrection(turn: Turn | undefined): boolean {
62
+ return turn?.confirmed === true;
63
+ }
64
+
65
+ const emptyEffect = (): RuleEffect => ({
66
+ fired: 0,
67
+ sentBack: 0,
68
+ judged: 0,
69
+ correctedAfter: 0,
70
+ });
71
+
72
+ export function ruleEffects(
73
+ events: RuleEvent[],
74
+ turns: Turn[]
75
+ ): Record<string, RuleEffect> {
76
+ const effects: Record<string, RuleEffect> = {};
77
+ for (const event of events) {
78
+ const effect = effects[event.rule] ?? emptyEffect();
79
+ const judge = turnAfter(event, turns);
80
+ effect.fired += 1;
81
+ if (event.sentBack) effect.sentBack += 1;
82
+ if (judge) effect.judged += 1;
83
+ if (isConfirmedCorrection(judge)) effect.correctedAfter += 1;
84
+ effects[event.rule] = effect;
85
+ }
86
+ return effects;
87
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The proof: replay a candidate's trigger over the turn log. A trigger earns a
3
+ * draft when it fires on repeated corrections and stays quiet on ordinary turns.
4
+ */
5
+
6
+ import type { RuleTrigger, TriggerProof } from "./adaptation-rules";
7
+ import type { RequestedTurn } from "./adaptation-turns";
8
+
9
+ const MIN_FIRED_CORRECTIONS = 2;
10
+ const MAX_ORDINARY_FIRE_RATE = 0.1;
11
+ const MIN_ORDINARY_TURNS = 20;
12
+
13
+ export type ProofVerdict = "passed" | "failed" | "waiting";
14
+
15
+ const CORRECTION_REACTIONS = new Set(["corrected", "repeated"]);
16
+
17
+ function isConfirmedCorrection(turn: RequestedTurn): boolean {
18
+ return CORRECTION_REACTIONS.has(turn.reaction) && turn.confirmed === true;
19
+ }
20
+
21
+ function isOrdinary(turn: RequestedTurn): boolean {
22
+ return !CORRECTION_REACTIONS.has(turn.reaction);
23
+ }
24
+
25
+ function triggerText(trigger: RuleTrigger, turn: RequestedTurn): string {
26
+ return trigger.side === "reply" ? turn.replyEnd : turn.prompt;
27
+ }
28
+
29
+ export function proveTrigger(trigger: RuleTrigger, turns: RequestedTurn[]): TriggerProof {
30
+ const pattern = new RegExp(trigger.pattern, "i");
31
+ const testable = turns.filter((turn) => triggerText(trigger, turn) !== "");
32
+ const fires = (turn: RequestedTurn) => pattern.test(triggerText(trigger, turn));
33
+ const corrections = testable.filter(isConfirmedCorrection);
34
+ const ordinary = testable.filter(isOrdinary);
35
+ return {
36
+ firedCorrections: corrections.filter(fires).length,
37
+ corrections: corrections.length,
38
+ firedOrdinary: ordinary.filter(fires).length,
39
+ ordinary: ordinary.length,
40
+ };
41
+ }
42
+
43
+ export function ordinaryTurnsNeeded(proof: TriggerProof): number {
44
+ return Math.max(0, MIN_ORDINARY_TURNS - proof.ordinary);
45
+ }
46
+
47
+ export function proofVerdict(proof: TriggerProof): ProofVerdict {
48
+ if (proof.firedCorrections < MIN_FIRED_CORRECTIONS) return "failed";
49
+ if (proof.ordinary < MIN_ORDINARY_TURNS) return "waiting";
50
+ return proof.firedOrdinary <= proof.ordinary * MAX_ORDINARY_FIRE_RATE
51
+ ? "passed"
52
+ : "failed";
53
+ }
@@ -10,6 +10,7 @@ import { ensureDir, paths } from "./paths";
10
10
 
11
11
  type TokenCaller =
12
12
  | "rating"
13
+ | "rule-drafter"
13
14
  | "failure"
14
15
  | "session-name"
15
16
  | "session-intelligence"
@@ -179,11 +179,12 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
179
179
  const text = partsToText(output.parts ?? []);
180
180
  if (!text.trim()) return;
181
181
 
182
+ const sentAt = new Date();
182
183
  const injectedText = (await getPromptContext(text, input.sessionID)) ?? "";
183
184
  logPromptSnapshot(text, injectedText || null);
184
185
 
185
186
  await Promise.allSettled([
186
- captureRating(text, input.sessionID),
187
+ captureRating(text, input.sessionID, sentAt),
187
188
  captureSessionName(text, input.sessionID),
188
189
  ]);
189
190
 
@@ -9,6 +9,7 @@
9
9
 
10
10
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
11
11
  import { resolve } from "node:path";
12
+ import { readRules } from "../../hooks/lib/adaptation-rules";
12
13
  import { ensureDir, paths } from "../../hooks/lib/paths";
13
14
  import { readAllProjects } from "../../hooks/lib/projects";
14
15
  import { queryLedger } from "../ledger/query";
@@ -127,10 +128,27 @@ function unranked(): AttentionItem[] {
127
128
  }));
128
129
  }
129
130
 
131
+ function ruleDrafts(): AttentionItem[] {
132
+ return readRules()
133
+ .filter((rule) => rule.status === "draft")
134
+ .map((rule) => ({
135
+ id: `rule:${rule.id}`,
136
+ source: "rules" as const,
137
+ severity: "waiting" as const,
138
+ title: `Rule draft waiting: ${rule.when}`,
139
+ detail: rule.steering,
140
+ at: rule.createdAt,
141
+ project: null,
142
+ href: "/relationship",
143
+ read: false,
144
+ }));
145
+ }
146
+
130
147
  const SOURCES: Record<AttentionSource, (now: Date) => AttentionItem[]> = {
131
148
  refusals,
132
149
  waiting,
133
150
  unranked: () => unranked(),
151
+ rules: () => ruleDrafts(),
134
152
  };
135
153
 
136
154
  export function attention(now: Date = new Date()): AttentionView {
@@ -14,7 +14,7 @@ import {
14
14
  write as writeSettings,
15
15
  } from "../../hooks/lib/settings";
16
16
 
17
- const ATTENTION_SOURCES = ["refusals", "waiting", "unranked"] as const;
17
+ const ATTENTION_SOURCES = ["refusals", "waiting", "unranked", "rules"] as const;
18
18
  export type AttentionSource = (typeof ATTENTION_SOURCES)[number];
19
19
 
20
20
  export interface ControlRoomPrefs {
@@ -28,7 +28,7 @@ const PREF_DEFAULTS: ControlRoomPrefs = {
28
28
  quietAfterDays: PROJECT_STALE_DAYS_DEFAULT,
29
29
  urgentWithinDays: 14,
30
30
  rankGoals: true,
31
- attention: { refusals: true, waiting: true, unranked: true },
31
+ attention: { refusals: true, waiting: true, unranked: true, rules: true },
32
32
  };
33
33
 
34
34
  const DAY_LIMIT = 365;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The Relationship tab: the rule drafts waiting on the user, what the approved
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.
6
+ */
7
+
8
+ import { type Candidate, readCandidates } from "../../hooks/lib/adaptation-candidates";
9
+ import {
10
+ type AdaptationRule,
11
+ decideRule,
12
+ readRules,
13
+ } from "../../hooks/lib/adaptation-rules";
14
+ import { readTurns, type Turn } from "../../hooks/lib/adaptation-turns";
15
+ import {
16
+ type RuleEffect,
17
+ readRuleEvents,
18
+ ruleEffects,
19
+ } from "../../hooks/lib/rule-effect";
20
+ import { ordinaryTurnsNeeded } from "../../hooks/lib/rule-proof";
21
+ import type { WriteOutcome } from "./writes";
22
+
23
+ const WINDOW_DAYS = 30;
24
+ const RECENT_CORRECTIONS = 10;
25
+
26
+ export interface ActiveRule extends AdaptationRule {
27
+ effect: RuleEffect;
28
+ }
29
+
30
+ export interface PipelineCandidate extends Candidate {
31
+ ordinaryNeeded: number;
32
+ }
33
+
34
+ interface TurnStats {
35
+ total: number;
36
+ byReaction: Record<string, number>;
37
+ confirmedCorrections: number;
38
+ recentCorrections: { ts: string; issue: string; message: string }[];
39
+ }
40
+
41
+ export interface RelationshipView {
42
+ windowDays: number;
43
+ drafts: AdaptationRule[];
44
+ active: ActiveRule[];
45
+ denied: AdaptationRule[];
46
+ pipeline: PipelineCandidate[];
47
+ turns: TurnStats;
48
+ }
49
+
50
+ function turnStats(turns: Turn[]): TurnStats {
51
+ const byReaction: Record<string, number> = {};
52
+ for (const turn of turns)
53
+ byReaction[turn.reaction] = (byReaction[turn.reaction] ?? 0) + 1;
54
+ const confirmed = turns.filter((turn) => turn.confirmed === true);
55
+ return {
56
+ total: turns.length,
57
+ byReaction,
58
+ confirmedCorrections: confirmed.length,
59
+ recentCorrections: confirmed
60
+ .slice(-RECENT_CORRECTIONS)
61
+ .reverse()
62
+ .map(({ ts, issue, message }) => ({ ts, issue, message })),
63
+ };
64
+ }
65
+
66
+ const NO_EFFECT: RuleEffect = { fired: 0, sentBack: 0, judged: 0, correctedAfter: 0 };
67
+
68
+ export function relationship(now: Date = new Date()): RelationshipView {
69
+ const rules = readRules();
70
+ const turns = readTurns(WINDOW_DAYS, now);
71
+ const effects = ruleEffects(readRuleEvents(WINDOW_DAYS, now), turns);
72
+ const withStatus = (status: AdaptationRule["status"]) =>
73
+ rules.filter((rule) => rule.status === status);
74
+ return {
75
+ windowDays: WINDOW_DAYS,
76
+ drafts: withStatus("draft"),
77
+ active: withStatus("approved").map((rule) => ({
78
+ ...rule,
79
+ effect: effects[rule.id] ?? NO_EFFECT,
80
+ })),
81
+ 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
+ })),
89
+ turns: turnStats(turns),
90
+ };
91
+ }
92
+
93
+ export function decideFromPage(
94
+ id: string,
95
+ decision: "approved" | "denied"
96
+ ): WriteOutcome {
97
+ const known = readRules().some((rule) => rule.id === id);
98
+ const result = decideRule(id, decision);
99
+ if (result.ok) return { ok: true, changed: true };
100
+ return { ok: false, status: known ? 409 : 404, error: result.reason };
101
+ }
@@ -18,6 +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
22
  import { DEFAULT_PORT, LOOPBACK } from "./server-config";
22
23
  import { MAX_SNOOZE_DAYS, setSnooze } from "./snooze";
23
24
  import { indexHtml, staticAsset } from "./static";
@@ -39,7 +40,14 @@ export { DEFAULT_PORT, LOOPBACK };
39
40
  * Every path the single-page app owns. Listed rather than wildcarded, because a
40
41
  * blanket "/*" outranks the fetch handler and would swallow /api as well.
41
42
  */
42
- const PAGE_ROUTES = ["/", "/projects", "/projects/:slug", "/log", "/settings"];
43
+ const PAGE_ROUTES = [
44
+ "/",
45
+ "/projects",
46
+ "/projects/:slug",
47
+ "/log",
48
+ "/relationship",
49
+ "/settings",
50
+ ];
43
51
 
44
52
  export interface ServerStatus {
45
53
  pid: number;
@@ -284,7 +292,20 @@ async function prefsWrite(request: Request): Promise<Response> {
284
292
  return failure ? json({ error: failure }, 400) : json(writePrefs(body as PrefUpdate));
285
293
  }
286
294
 
295
+ /** Approve or deny a rule draft — the same rule `pal cli rule` reaches. */
296
+ async function ruleWrite(request: Request): Promise<Response> {
297
+ const body = await readBody(request);
298
+ if (!body) return json({ error: "expected a JSON body" }, 400);
299
+ const { id, decision } = body;
300
+ if (typeof id !== "string" || !id) return json({ error: "id is required" }, 400);
301
+ if (decision !== "approved" && decision !== "denied") {
302
+ return json({ error: "decision must be approved or denied" }, 400);
303
+ }
304
+ return answer(decideFromPage(id, decision), { id, decision });
305
+ }
306
+
287
307
  const WRITES: Record<string, (request: Request) => Promise<Response>> = {
308
+ "/api/rule": ruleWrite,
288
309
  "/api/serves": overrideServes,
289
310
  "/api/isc": iscWrite,
290
311
  "/api/placement": placementWrite,
@@ -340,6 +361,8 @@ export function startControlRoom(port: number = DEFAULT_PORT) {
340
361
  return json(autoUpdateStatus());
341
362
  case "/api/attention":
342
363
  return json(attention());
364
+ case "/api/relationship":
365
+ return json(relationship());
343
366
  case "/api/status":
344
367
  return status(server.port ?? port, startedAt);
345
368
  default:
@@ -22,6 +22,7 @@ import {
22
22
  STATUS_FILTERS,
23
23
  type StatusFilter,
24
24
  } from "./screens/projects";
25
+ import { Relationship } from "./screens/relationship";
25
26
  import { Settings } from "./screens/settings";
26
27
  import { isLayout, LAYOUTS, type Layout, Today } from "./screens/today";
27
28
 
@@ -126,6 +127,23 @@ function LogRoute() {
126
127
  );
127
128
  }
128
129
 
130
+ function RelationshipRoute() {
131
+ return (
132
+ <Screen
133
+ title="Relationship"
134
+ aside={
135
+ <span className="text-[12px] text-neutral-700">
136
+ rules drafted from your corrections, and what they did
137
+ </span>
138
+ }
139
+ >
140
+ <Scroller className={MARK_ROOM}>
141
+ <Relationship />
142
+ </Scroller>
143
+ </Screen>
144
+ );
145
+ }
146
+
129
147
  function SettingsRoute() {
130
148
  return (
131
149
  <Screen title="Settings">
@@ -140,6 +158,7 @@ const TABS = [
140
158
  { to: "/", label: "Today", end: true },
141
159
  { to: "/projects", label: "Projects", end: false },
142
160
  { to: "/log", label: "Action log", end: false },
161
+ { to: "/relationship", label: "Relationship", end: false },
143
162
  { to: "/settings", label: "Settings", end: false },
144
163
  ] as const;
145
164
 
@@ -194,6 +213,7 @@ function App() {
194
213
  <Route path="/projects" element={<ProjectsRoute />} />
195
214
  <Route path="/projects/:slug" element={<ProjectDetailRoute />} />
196
215
  <Route path="/log" element={<LogRoute />} />
216
+ <Route path="/relationship" element={<RelationshipRoute />} />
197
217
  <Route path="/settings" element={<SettingsRoute />} />
198
218
  <Route path="*" element={<Navigate to="/" replace />} />
199
219
  </Routes>