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.
- package/assets/schema/pal-settings.schema.json +4 -0
- package/assets/skills/rule-review/SKILL.md +6 -0
- package/assets/templates/pal-settings.json +1 -0
- package/package.json +1 -1
- package/src/cli/rule.ts +7 -0
- 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 +49 -7
- package/src/hooks/lib/adaptation-candidates.ts +89 -0
- package/src/hooks/lib/adaptation-rules.ts +12 -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 +168 -0
- package/src/hooks/lib/rule-effect.ts +87 -0
- package/src/hooks/lib/rule-proof.ts +53 -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-Dd5J8No5.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 +224 -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
|
@@ -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.
|
package/package.json
CHANGED
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,10 +10,17 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
-
import {
|
|
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,12 @@ import {
|
|
|
24
31
|
reactionRequest,
|
|
25
32
|
turnFromLabels,
|
|
26
33
|
} from "../lib/reaction-rating";
|
|
34
|
+
import {
|
|
35
|
+
canRepeat,
|
|
36
|
+
correctionsToDraftFrom,
|
|
37
|
+
drafterRequest,
|
|
38
|
+
parseDraft,
|
|
39
|
+
} from "../lib/rule-drafter";
|
|
27
40
|
import { emitRating } from "../lib/signals";
|
|
28
41
|
import { now } from "../lib/time";
|
|
29
42
|
import { logTokenUsage } from "../lib/token-usage";
|
|
@@ -173,7 +186,7 @@ function handleRating(
|
|
|
173
186
|
|
|
174
187
|
// ── Implicit Rating ──
|
|
175
188
|
|
|
176
|
-
function handleImplicitReaction(message: string, sessionId?: string): void {
|
|
189
|
+
function handleImplicitReaction(message: string, sentAt: Date, sessionId?: string): void {
|
|
177
190
|
const trimmed = message.trim();
|
|
178
191
|
const reply = replyEnd(getLastResponse(sessionId));
|
|
179
192
|
|
|
@@ -199,6 +212,7 @@ function handleImplicitReaction(message: string, sessionId?: string): void {
|
|
|
199
212
|
sessionId ?? "",
|
|
200
213
|
Buffer.from(trimmed).toString("base64"),
|
|
201
214
|
Buffer.from(reply).toString("base64"),
|
|
215
|
+
sentAt.toISOString(),
|
|
202
216
|
],
|
|
203
217
|
"rating"
|
|
204
218
|
);
|
|
@@ -210,11 +224,32 @@ async function labelReaction(reply: string, message: string, sessionId?: string)
|
|
|
210
224
|
return result.success ? parseReactionLabel(result.output) : null;
|
|
211
225
|
}
|
|
212
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
|
+
|
|
213
247
|
/** Background mode: label the reaction, confirm a correction, store the rating. */
|
|
214
248
|
async function runReactionRatingAndStore(
|
|
215
249
|
message: string,
|
|
216
250
|
reply: string,
|
|
217
|
-
sessionId
|
|
251
|
+
sessionId: string | undefined,
|
|
252
|
+
sentAt: Date
|
|
218
253
|
): Promise<void> {
|
|
219
254
|
try {
|
|
220
255
|
const first = await labelReaction(reply, message, sessionId);
|
|
@@ -230,7 +265,8 @@ async function runReactionRatingAndStore(
|
|
|
230
265
|
first,
|
|
231
266
|
confirmation
|
|
232
267
|
);
|
|
233
|
-
if (turn) appendTurn(turn);
|
|
268
|
+
if (turn) appendTurn(turn, sentAt);
|
|
269
|
+
if (turn?.confirmed) await draftRuleCandidate(sessionId);
|
|
234
270
|
} catch (err) {
|
|
235
271
|
const { logError } = await import("../lib/log");
|
|
236
272
|
logError("rating:reaction-child", err);
|
|
@@ -239,7 +275,11 @@ async function runReactionRatingAndStore(
|
|
|
239
275
|
|
|
240
276
|
// ── Main Export ──
|
|
241
277
|
|
|
242
|
-
export function captureRating(
|
|
278
|
+
export function captureRating(
|
|
279
|
+
message: string,
|
|
280
|
+
sessionId?: string,
|
|
281
|
+
sentAt: Date = new Date()
|
|
282
|
+
): void {
|
|
243
283
|
// Strip IDE/system-injected tags to recover raw user text
|
|
244
284
|
const cleaned = stripInjectedTags(message);
|
|
245
285
|
|
|
@@ -258,7 +298,7 @@ export function captureRating(message: string, sessionId?: string): void {
|
|
|
258
298
|
|
|
259
299
|
// Path 2: Implicit reaction — the praise fast-path runs synchronously, the
|
|
260
300
|
// model path detaches to a background bun subprocess (mirrors session-name).
|
|
261
|
-
handleImplicitReaction(cleaned, sessionId);
|
|
301
|
+
handleImplicitReaction(cleaned, sentAt, sessionId);
|
|
262
302
|
}
|
|
263
303
|
|
|
264
304
|
// Background reaction entry point
|
|
@@ -268,10 +308,12 @@ if (process.argv[2] === "--sentiment") {
|
|
|
268
308
|
const replyB64 = process.argv[5];
|
|
269
309
|
if (msgB64 && replyB64) {
|
|
270
310
|
const decode = (b64: string) => Buffer.from(b64, "base64").toString("utf-8");
|
|
311
|
+
const sentAt = new Date(process.argv[6] ?? "");
|
|
271
312
|
await runReactionRatingAndStore(
|
|
272
313
|
decode(msgB64),
|
|
273
314
|
decode(replyB64),
|
|
274
|
-
sid === "" ? undefined : sid
|
|
315
|
+
sid === "" ? undefined : sid,
|
|
316
|
+
Number.isNaN(sentAt.getTime()) ? new Date() : sentAt
|
|
275
317
|
);
|
|
276
318
|
}
|
|
277
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
|
+
}
|
|
@@ -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
|
|
|
@@ -0,0 +1,168 @@
|
|
|
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 RequestedTurn, type Turn, withRequests } from "./adaptation-turns";
|
|
9
|
+
|
|
10
|
+
const MIN_CITED = 2;
|
|
11
|
+
const EVIDENCE_MESSAGE_MAX = 200;
|
|
12
|
+
|
|
13
|
+
type Correction = RequestedTurn;
|
|
14
|
+
|
|
15
|
+
export interface KnownRule {
|
|
16
|
+
when: string;
|
|
17
|
+
steering: string;
|
|
18
|
+
status: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DRAFTER_SYSTEM_PROMPT = `You turn an AI assistant's repeated mistakes into one rule that prevents them.
|
|
22
|
+
|
|
23
|
+
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.
|
|
24
|
+
|
|
25
|
+
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.
|
|
26
|
+
|
|
27
|
+
If it is not, or a 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 [].
|
|
28
|
+
|
|
29
|
+
If it is, draft one rule:
|
|
30
|
+
- when: the situation, in one sentence.
|
|
31
|
+
- 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.
|
|
32
|
+
- steering: the instruction to the assistant, imperative, at most two sentences.
|
|
33
|
+
- check: how to tell from a reply whether the instruction was followed, in one sentence.
|
|
34
|
+
- cites: the numbers of the corrections this rule would have prevented, including the newest.
|
|
35
|
+
- reason: why these corrections are the same mistake, in one sentence.`;
|
|
36
|
+
|
|
37
|
+
const DRAFTER_SCHEMA = {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
recurring: { type: "boolean" },
|
|
41
|
+
reason: { type: "string" },
|
|
42
|
+
when: { type: "string" },
|
|
43
|
+
side: { enum: ["prompt", "reply"] },
|
|
44
|
+
pattern: { type: "string" },
|
|
45
|
+
steering: { type: "string" },
|
|
46
|
+
check: { type: "string" },
|
|
47
|
+
cites: { type: "array", items: { type: "integer" } },
|
|
48
|
+
},
|
|
49
|
+
required: [
|
|
50
|
+
"recurring",
|
|
51
|
+
"reason",
|
|
52
|
+
"when",
|
|
53
|
+
"side",
|
|
54
|
+
"pattern",
|
|
55
|
+
"steering",
|
|
56
|
+
"check",
|
|
57
|
+
"cites",
|
|
58
|
+
],
|
|
59
|
+
additionalProperties: false,
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
export function correctionsToDraftFrom(turns: Turn[]): Correction[] {
|
|
63
|
+
return withRequests(turns).filter((turn) => turn.confirmed === true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function canRepeat(corrections: Correction[]): boolean {
|
|
67
|
+
return corrections.length >= MIN_CITED;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function correctionBlock(correction: Correction, n: number, newest: boolean): string {
|
|
71
|
+
const request = correction.prompt ? `<request>${correction.prompt}</request>\n` : "";
|
|
72
|
+
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>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function knownRulesBlock(known: KnownRule[]): string {
|
|
76
|
+
const lines = known.map((rule) => `- ${rule.status}: ${rule.when}. ${rule.steering}`);
|
|
77
|
+
return `Known rules:\n${lines.length ? lines.join("\n") : "none"}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function drafterRequest(
|
|
81
|
+
corrections: Correction[],
|
|
82
|
+
known: KnownRule[],
|
|
83
|
+
sessionId?: string
|
|
84
|
+
) {
|
|
85
|
+
const last = corrections.length - 1;
|
|
86
|
+
const blocks = corrections.map((c, n) => correctionBlock(c, n, n === last));
|
|
87
|
+
return {
|
|
88
|
+
system: DRAFTER_SYSTEM_PROMPT,
|
|
89
|
+
user: `${blocks.join("\n\n")}\n\n${knownRulesBlock(known)}`,
|
|
90
|
+
tier: "small" as const,
|
|
91
|
+
maxTokens: 600,
|
|
92
|
+
timeout: 90_000,
|
|
93
|
+
jsonSchema: DRAFTER_SCHEMA,
|
|
94
|
+
caller: "rule-drafter",
|
|
95
|
+
sessionId,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface DraftOutput {
|
|
100
|
+
recurring: boolean;
|
|
101
|
+
when: string;
|
|
102
|
+
side: "prompt" | "reply";
|
|
103
|
+
pattern: string;
|
|
104
|
+
steering: string;
|
|
105
|
+
check: string;
|
|
106
|
+
cites: number[];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const REGEX_LITERAL = /^\/(.+)\/[a-z]*$/s;
|
|
110
|
+
|
|
111
|
+
function bareExpression(pattern: string): string {
|
|
112
|
+
return REGEX_LITERAL.exec(pattern)?.[1] ?? pattern;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function isValidPattern(pattern: string): boolean {
|
|
116
|
+
try {
|
|
117
|
+
new RegExp(pattern, "i");
|
|
118
|
+
return pattern.trim() !== "";
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validCites(cites: unknown, count: number): number[] | null {
|
|
125
|
+
if (!Array.isArray(cites)) return null;
|
|
126
|
+
const distinct = [...new Set(cites)];
|
|
127
|
+
const inRange = distinct.every((n) => Number.isInteger(n) && n >= 0 && n < count);
|
|
128
|
+
const citesNewest = distinct.includes(count - 1);
|
|
129
|
+
return inRange && citesNewest && distinct.length >= MIN_CITED
|
|
130
|
+
? (distinct as number[]).sort((a, b) => a - b)
|
|
131
|
+
: null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function parseOutput(output: string): DraftOutput | null {
|
|
135
|
+
try {
|
|
136
|
+
return JSON.parse(output) as DraftOutput;
|
|
137
|
+
} catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function hasText(...fields: unknown[]): boolean {
|
|
143
|
+
return fields.every((field) => typeof field === "string" && field.trim() !== "");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function parseDraft(
|
|
147
|
+
output: string | null | undefined,
|
|
148
|
+
corrections: Correction[]
|
|
149
|
+
): CandidateInput | null {
|
|
150
|
+
const draft = output ? parseOutput(output) : null;
|
|
151
|
+
if (draft?.recurring !== true) return null;
|
|
152
|
+
const cites = validCites(draft.cites, corrections.length);
|
|
153
|
+
const sideOk = draft.side === "prompt" || draft.side === "reply";
|
|
154
|
+
if (!cites || !sideOk || !hasText(draft.pattern)) return null;
|
|
155
|
+
const pattern = bareExpression(draft.pattern);
|
|
156
|
+
if (!isValidPattern(pattern)) return null;
|
|
157
|
+
if (!hasText(draft.when, draft.steering, draft.check)) return null;
|
|
158
|
+
return {
|
|
159
|
+
when: draft.when.trim(),
|
|
160
|
+
trigger: { side: draft.side, pattern },
|
|
161
|
+
steering: draft.steering.trim(),
|
|
162
|
+
check: draft.check.trim(),
|
|
163
|
+
evidence: cites.map((n) => {
|
|
164
|
+
const cited = corrections[n];
|
|
165
|
+
return `${cited.issue}: ${cited.message.slice(0, EVIDENCE_MESSAGE_MAX)}`;
|
|
166
|
+
}),
|
|
167
|
+
};
|
|
168
|
+
}
|