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.
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/rule-review/SKILL.md +9 -0
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +1 -1
- package/src/cli/rule.ts +14 -1
- package/src/hooks/StopOrchestrator.ts +10 -0
- package/src/hooks/UserPromptOrchestrator.ts +2 -1
- package/src/hooks/handlers/inject-retrieval.ts +2 -0
- package/src/hooks/handlers/rating.ts +51 -7
- package/src/hooks/lib/adaptation-candidates.ts +95 -0
- package/src/hooks/lib/adaptation-rules.ts +36 -1
- package/src/hooks/lib/adaptation-steering.ts +87 -0
- package/src/hooks/lib/adaptation-turns.ts +20 -0
- package/src/hooks/lib/claim-log.ts +1 -1
- package/src/hooks/lib/rule-drafter.ts +240 -0
- package/src/hooks/lib/rule-effect.ts +87 -0
- package/src/hooks/lib/rule-proof.ts +65 -0
- package/src/hooks/lib/token-usage.ts +1 -0
- package/src/targets/opencode/plugin.ts +2 -1
- package/src/tools/control-room/attention.ts +18 -0
- package/src/tools/control-room/prefs.ts +2 -2
- package/src/tools/control-room/relationship.ts +101 -0
- package/src/tools/control-room/server.ts +24 -1
- package/src/tools/control-room/ui/app.tsx +20 -0
- package/src/tools/control-room/ui/dist/assets/index-BZMDzWFa.js +49 -0
- package/src/tools/control-room/ui/dist/assets/index-DeglMbfS.css +2 -0
- package/src/tools/control-room/ui/dist/index.html +2 -2
- package/src/tools/control-room/ui/lib/write.ts +4 -0
- package/src/tools/control-room/ui/screens/relationship.tsx +241 -0
- package/src/tools/control-room/ui/screens/settings.tsx +1 -0
- package/src/tools/control-room/ui/dist/assets/index-D_o9Usd_.js +0 -49
- package/src/tools/control-room/ui/dist/assets/index-Dncp2bYg.css +0 -2
|
@@ -0,0 +1,240 @@
|
|
|
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 { RuleTrigger } from "./adaptation-rules";
|
|
9
|
+
import { type RequestedTurn, type Turn, withRequests } from "./adaptation-turns";
|
|
10
|
+
import { firesOn } from "./rule-proof";
|
|
11
|
+
|
|
12
|
+
const MIN_CITED = 2;
|
|
13
|
+
const MIN_CITED_FOR_WIDENING = 1;
|
|
14
|
+
const EVIDENCE_MESSAGE_MAX = 200;
|
|
15
|
+
|
|
16
|
+
type Correction = RequestedTurn;
|
|
17
|
+
|
|
18
|
+
export interface KnownRule {
|
|
19
|
+
id: string;
|
|
20
|
+
when: string;
|
|
21
|
+
trigger: RuleTrigger;
|
|
22
|
+
steering: string;
|
|
23
|
+
check?: string;
|
|
24
|
+
status: string;
|
|
25
|
+
widens?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DRAFTER_SYSTEM_PROMPT = `You turn an AI assistant's repeated mistakes into one rule that prevents them.
|
|
29
|
+
|
|
30
|
+
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. Then the known rules; an approved rule shows its trigger and whether that trigger fires on the newest correction.
|
|
31
|
+
|
|
32
|
+
First check the approved rules. If one would have prevented the newest correction but its trigger does not fire on it, widen that rule: set recurring to true, widens to its id, pattern to a case-insensitive JavaScript regular expression on the rule's side that matches the newest correction (it is added to the rule's trigger as an alternative, so do not repeat the existing trigger), cites to the corrections the widened rule would have prevented, including the newest, and reason. Leave when, steering and check empty.
|
|
33
|
+
|
|
34
|
+
If an approved rule would have prevented it and its trigger fires on it, the rule fired and was not followed; a new rule will not help. Set recurring to false.
|
|
35
|
+
|
|
36
|
+
Otherwise set widens to an empty string and 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.
|
|
37
|
+
|
|
38
|
+
If it is not, or another 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 [].
|
|
39
|
+
|
|
40
|
+
If it is, draft one rule:
|
|
41
|
+
- when: the situation, in one sentence.
|
|
42
|
+
- 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.
|
|
43
|
+
- steering: the instruction to the assistant, imperative, at most two sentences.
|
|
44
|
+
- check: how to tell from a reply whether the instruction was followed, in one sentence.
|
|
45
|
+
- cites: the numbers of the corrections this rule would have prevented, including the newest.
|
|
46
|
+
- reason: why these corrections are the same mistake, in one sentence.`;
|
|
47
|
+
|
|
48
|
+
const DRAFTER_SCHEMA = {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
recurring: { type: "boolean" },
|
|
52
|
+
widens: { type: "string" },
|
|
53
|
+
reason: { type: "string" },
|
|
54
|
+
when: { type: "string" },
|
|
55
|
+
side: { enum: ["prompt", "reply"] },
|
|
56
|
+
pattern: { type: "string" },
|
|
57
|
+
steering: { type: "string" },
|
|
58
|
+
check: { type: "string" },
|
|
59
|
+
cites: { type: "array", items: { type: "integer" } },
|
|
60
|
+
},
|
|
61
|
+
required: [
|
|
62
|
+
"recurring",
|
|
63
|
+
"widens",
|
|
64
|
+
"reason",
|
|
65
|
+
"when",
|
|
66
|
+
"side",
|
|
67
|
+
"pattern",
|
|
68
|
+
"steering",
|
|
69
|
+
"check",
|
|
70
|
+
"cites",
|
|
71
|
+
],
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
} as const;
|
|
74
|
+
|
|
75
|
+
export function correctionsToDraftFrom(turns: Turn[]): Correction[] {
|
|
76
|
+
return withRequests(turns).filter((turn) => turn.confirmed === true);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function canRepeat(corrections: Correction[]): boolean {
|
|
80
|
+
return corrections.length >= MIN_CITED;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function missedApprovedRules(corrections: Correction[], known: KnownRule[]): KnownRule[] {
|
|
84
|
+
const newest = corrections.at(-1);
|
|
85
|
+
if (!newest) return [];
|
|
86
|
+
return known.filter(
|
|
87
|
+
(rule) => rule.status === "approved" && !firesOn(rule.trigger, newest)
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function canWiden(corrections: Correction[], known: KnownRule[]): boolean {
|
|
92
|
+
return missedApprovedRules(corrections, known).length > 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function correctionBlock(correction: Correction, n: number, newest: boolean): string {
|
|
96
|
+
const request = correction.prompt ? `<request>${correction.prompt}</request>\n` : "";
|
|
97
|
+
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>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function knownRuleLine(rule: KnownRule, newest: Correction | undefined): string {
|
|
101
|
+
if (rule.widens)
|
|
102
|
+
return `- draft widening of rule ${rule.widens}: ${rule.trigger.pattern}`;
|
|
103
|
+
if (rule.status !== "approved")
|
|
104
|
+
return `- ${rule.status}: ${rule.when}. ${rule.steering}`;
|
|
105
|
+
const fires = newest && firesOn(rule.trigger, newest) ? "yes" : "no";
|
|
106
|
+
return `- approved rule ${rule.id}: ${rule.when}. ${rule.steering} Trigger on the ${rule.trigger.side}: ${rule.trigger.pattern}. Fires on the newest correction: ${fires}.`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function knownRulesBlock(known: KnownRule[], newest: Correction | undefined): string {
|
|
110
|
+
const lines = known.map((rule) => knownRuleLine(rule, newest));
|
|
111
|
+
return `Known rules:\n${lines.length ? lines.join("\n") : "none"}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function drafterRequest(
|
|
115
|
+
corrections: Correction[],
|
|
116
|
+
known: KnownRule[],
|
|
117
|
+
sessionId?: string
|
|
118
|
+
) {
|
|
119
|
+
const last = corrections.length - 1;
|
|
120
|
+
const blocks = corrections.map((c, n) => correctionBlock(c, n, n === last));
|
|
121
|
+
return {
|
|
122
|
+
system: DRAFTER_SYSTEM_PROMPT,
|
|
123
|
+
user: `${blocks.join("\n\n")}\n\n${knownRulesBlock(known, corrections[last])}`,
|
|
124
|
+
tier: "small" as const,
|
|
125
|
+
maxTokens: 600,
|
|
126
|
+
timeout: 90_000,
|
|
127
|
+
jsonSchema: DRAFTER_SCHEMA,
|
|
128
|
+
caller: "rule-drafter",
|
|
129
|
+
sessionId,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface DraftOutput {
|
|
134
|
+
recurring: boolean;
|
|
135
|
+
widens?: string;
|
|
136
|
+
when: string;
|
|
137
|
+
side: "prompt" | "reply";
|
|
138
|
+
pattern: string;
|
|
139
|
+
steering: string;
|
|
140
|
+
check: string;
|
|
141
|
+
cites: number[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const REGEX_LITERAL = /^\/(.+)\/[a-z]*$/s;
|
|
145
|
+
|
|
146
|
+
function bareExpression(pattern: string): string {
|
|
147
|
+
return REGEX_LITERAL.exec(pattern)?.[1] ?? pattern;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isValidPattern(pattern: string): boolean {
|
|
151
|
+
try {
|
|
152
|
+
new RegExp(pattern, "i");
|
|
153
|
+
return pattern.trim() !== "";
|
|
154
|
+
} catch {
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function validCites(cites: unknown, count: number, minCited: number): number[] | null {
|
|
160
|
+
if (!Array.isArray(cites)) return null;
|
|
161
|
+
const distinct = [...new Set(cites)];
|
|
162
|
+
const inRange = distinct.every((n) => Number.isInteger(n) && n >= 0 && n < count);
|
|
163
|
+
const citesNewest = distinct.includes(count - 1);
|
|
164
|
+
return inRange && citesNewest && distinct.length >= minCited
|
|
165
|
+
? (distinct as number[]).sort((a, b) => a - b)
|
|
166
|
+
: null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseOutput(output: string): DraftOutput | null {
|
|
170
|
+
try {
|
|
171
|
+
return JSON.parse(output) as DraftOutput;
|
|
172
|
+
} catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function hasText(...fields: unknown[]): boolean {
|
|
178
|
+
return fields.every((field) => typeof field === "string" && field.trim() !== "");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function citedEvidence(cites: number[], corrections: Correction[]): string[] {
|
|
182
|
+
return cites.map((n) => {
|
|
183
|
+
const cited = corrections[n];
|
|
184
|
+
return `${cited.issue}: ${cited.message.slice(0, EVIDENCE_MESSAGE_MAX)}`;
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function parseNewRule(
|
|
189
|
+
draft: DraftOutput,
|
|
190
|
+
corrections: Correction[]
|
|
191
|
+
): CandidateInput | null {
|
|
192
|
+
const cites = validCites(draft.cites, corrections.length, MIN_CITED);
|
|
193
|
+
const sideOk = draft.side === "prompt" || draft.side === "reply";
|
|
194
|
+
if (!cites || !sideOk || !hasText(draft.pattern)) return null;
|
|
195
|
+
const pattern = bareExpression(draft.pattern);
|
|
196
|
+
if (!isValidPattern(pattern)) return null;
|
|
197
|
+
if (!hasText(draft.when, draft.steering, draft.check)) return null;
|
|
198
|
+
return {
|
|
199
|
+
when: draft.when.trim(),
|
|
200
|
+
trigger: { side: draft.side, pattern },
|
|
201
|
+
steering: draft.steering.trim(),
|
|
202
|
+
check: draft.check.trim(),
|
|
203
|
+
evidence: citedEvidence(cites, corrections),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function parseWidening(
|
|
208
|
+
draft: DraftOutput,
|
|
209
|
+
corrections: Correction[],
|
|
210
|
+
known: KnownRule[]
|
|
211
|
+
): CandidateInput | null {
|
|
212
|
+
const rule = missedApprovedRules(corrections, known).find((r) => r.id === draft.widens);
|
|
213
|
+
const cites = validCites(draft.cites, corrections.length, MIN_CITED_FOR_WIDENING);
|
|
214
|
+
if (!rule || !cites || !hasText(draft.pattern)) return null;
|
|
215
|
+
const addition = bareExpression(draft.pattern);
|
|
216
|
+
if (!isValidPattern(addition)) return null;
|
|
217
|
+
const trigger = { ...rule.trigger, pattern: `${rule.trigger.pattern}|${addition}` };
|
|
218
|
+
const newest = corrections.at(-1);
|
|
219
|
+
if (!newest || !firesOn(trigger, newest)) return null;
|
|
220
|
+
return {
|
|
221
|
+
when: rule.when,
|
|
222
|
+
trigger,
|
|
223
|
+
steering: rule.steering,
|
|
224
|
+
check: rule.check ?? "",
|
|
225
|
+
evidence: citedEvidence(cites, corrections),
|
|
226
|
+
widens: rule.id,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function parseDraft(
|
|
231
|
+
output: string | null | undefined,
|
|
232
|
+
corrections: Correction[],
|
|
233
|
+
known: KnownRule[] = []
|
|
234
|
+
): CandidateInput | null {
|
|
235
|
+
const draft = output ? parseOutput(output) : null;
|
|
236
|
+
if (draft?.recurring !== true) return null;
|
|
237
|
+
return draft.widens
|
|
238
|
+
? parseWidening(draft, corrections, known)
|
|
239
|
+
: parseNewRule(draft, corrections);
|
|
240
|
+
}
|
|
@@ -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,65 @@
|
|
|
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 MIN_FIRED_FOR_WIDENING = 1;
|
|
11
|
+
const MAX_ORDINARY_FIRE_RATE = 0.1;
|
|
12
|
+
const MIN_ORDINARY_TURNS = 20;
|
|
13
|
+
|
|
14
|
+
export type ProofVerdict = "passed" | "failed" | "waiting";
|
|
15
|
+
|
|
16
|
+
const CORRECTION_REACTIONS = new Set(["corrected", "repeated"]);
|
|
17
|
+
|
|
18
|
+
function isConfirmedCorrection(turn: RequestedTurn): boolean {
|
|
19
|
+
return CORRECTION_REACTIONS.has(turn.reaction) && turn.confirmed === true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isOrdinary(turn: RequestedTurn): boolean {
|
|
23
|
+
return !CORRECTION_REACTIONS.has(turn.reaction);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function triggerText(trigger: RuleTrigger, turn: RequestedTurn): string {
|
|
27
|
+
return trigger.side === "reply" ? turn.replyEnd : turn.prompt;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function firesOn(trigger: RuleTrigger, turn: RequestedTurn): boolean {
|
|
31
|
+
return new RegExp(trigger.pattern, "i").test(triggerText(trigger, turn));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function proveTrigger(trigger: RuleTrigger, turns: RequestedTurn[]): TriggerProof {
|
|
35
|
+
const testable = turns.filter((turn) => triggerText(trigger, turn) !== "");
|
|
36
|
+
const fires = (turn: RequestedTurn) => firesOn(trigger, turn);
|
|
37
|
+
const corrections = testable.filter(isConfirmedCorrection);
|
|
38
|
+
const ordinary = testable.filter(isOrdinary);
|
|
39
|
+
return {
|
|
40
|
+
firedCorrections: corrections.filter(fires).length,
|
|
41
|
+
corrections: corrections.length,
|
|
42
|
+
firedOrdinary: ordinary.filter(fires).length,
|
|
43
|
+
ordinary: ordinary.length,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ordinaryTurnsNeeded(proof: TriggerProof): number {
|
|
48
|
+
return Math.max(0, MIN_ORDINARY_TURNS - proof.ordinary);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function proofVerdict(proof: TriggerProof): ProofVerdict {
|
|
52
|
+
return verdictFiringOn(MIN_FIRED_CORRECTIONS, proof);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function widenedProofVerdict(proof: TriggerProof): ProofVerdict {
|
|
56
|
+
return verdictFiringOn(MIN_FIRED_FOR_WIDENING, proof);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function verdictFiringOn(minFiredCorrections: number, proof: TriggerProof): ProofVerdict {
|
|
60
|
+
if (proof.firedCorrections < minFiredCorrections) return "failed";
|
|
61
|
+
if (proof.ordinary < MIN_ORDINARY_TURNS) return "waiting";
|
|
62
|
+
return proof.firedOrdinary <= proof.ordinary * MAX_ORDINARY_FIRE_RATE
|
|
63
|
+
? "passed"
|
|
64
|
+
: "failed";
|
|
65
|
+
}
|
|
@@ -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 = [
|
|
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>
|