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
|
@@ -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
|
},
|
|
@@ -36,8 +36,11 @@ Each draft prints as:
|
|
|
36
36
|
|
|
37
37
|
```
|
|
38
38
|
<id> [draft] <situation>
|
|
39
|
+
widens: <id of an approved rule> (only on a widening)
|
|
39
40
|
trigger (<prompt|reply>): <pattern>
|
|
40
41
|
steering: <steering>
|
|
42
|
+
check: <how to tell the steering was followed>
|
|
43
|
+
proof: fired on <n>/<n> corrections, <n>/<n> ordinary turns
|
|
41
44
|
evidence: <line>
|
|
42
45
|
```
|
|
43
46
|
|
|
@@ -83,10 +86,16 @@ Present each draft as one compact block, nothing else in between:
|
|
|
83
86
|
Draft <id>
|
|
84
87
|
When: <situation>
|
|
85
88
|
Steer: <steering>
|
|
89
|
+
Check: <check>
|
|
90
|
+
Proof: fired on <n>/<n> corrections, <n>/<n> ordinary turns
|
|
86
91
|
Evidence: <evidence line>
|
|
87
92
|
<evidence line>
|
|
88
93
|
```
|
|
89
94
|
|
|
95
|
+
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.
|
|
96
|
+
|
|
97
|
+
A draft with a `widens:` line is not a new rule. An approved rule missed a correction because its trigger did not match the user's wording, and the draft is that rule with a wider trigger. Add a line `Widens: rule <id>; approving replaces its trigger with the one above` under When. Approving it updates the approved rule and the draft disappears; the CLI then reports `Rule <id> now triggers on: <pattern> (widened by <draft id>)`. Report that line verbatim too. Denying it leaves the approved rule as it was.
|
|
98
|
+
|
|
90
99
|
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
100
|
|
|
92
101
|
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">> = {
|
|
@@ -45,12 +46,24 @@ function cmdList(args: string[]): number {
|
|
|
45
46
|
function formatRule(rule: AdaptationRule): string {
|
|
46
47
|
return [
|
|
47
48
|
`${rule.id} [${rule.status}] ${rule.when}`,
|
|
49
|
+
...(rule.widens ? [` widens: ${rule.widens}`] : []),
|
|
48
50
|
` trigger (${rule.trigger.side}): ${rule.trigger.pattern}`,
|
|
49
51
|
` steering: ${rule.steering}`,
|
|
52
|
+
...(rule.check ? [` check: ${rule.check}`] : []),
|
|
53
|
+
...(rule.proof ? [` proof: ${formatProof(rule.proof)}`] : []),
|
|
50
54
|
...rule.evidence.map((e) => ` evidence: ${e}`),
|
|
51
55
|
].join("\n");
|
|
52
56
|
}
|
|
53
57
|
|
|
58
|
+
function formatProof(proof: TriggerProof): string {
|
|
59
|
+
return `fired on ${proof.firedCorrections}/${proof.corrections} corrections, ${proof.firedOrdinary}/${proof.ordinary} ordinary turns`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function decisionLine(id: string, decision: string, rule: AdaptationRule): string {
|
|
63
|
+
if (rule.id === id) return `Rule ${id} ${decision}: ${rule.when}`;
|
|
64
|
+
return `Rule ${rule.id} now triggers on: ${rule.trigger.pattern} (widened by ${id})`;
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
function cmdDecide(
|
|
55
68
|
id: string | undefined,
|
|
56
69
|
decision: Exclude<RuleStatus, "draft">
|
|
@@ -64,7 +77,7 @@ function cmdDecide(
|
|
|
64
77
|
console.error(result.reason);
|
|
65
78
|
return 1;
|
|
66
79
|
}
|
|
67
|
-
console.log(
|
|
80
|
+
console.log(decisionLine(id, decision, result.rule));
|
|
68
81
|
return 0;
|
|
69
82
|
}
|
|
70
83
|
|
|
@@ -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,13 @@ import {
|
|
|
24
31
|
reactionRequest,
|
|
25
32
|
turnFromLabels,
|
|
26
33
|
} from "../lib/reaction-rating";
|
|
34
|
+
import {
|
|
35
|
+
canRepeat,
|
|
36
|
+
canWiden,
|
|
37
|
+
correctionsToDraftFrom,
|
|
38
|
+
drafterRequest,
|
|
39
|
+
parseDraft,
|
|
40
|
+
} from "../lib/rule-drafter";
|
|
27
41
|
import { emitRating } from "../lib/signals";
|
|
28
42
|
import { now } from "../lib/time";
|
|
29
43
|
import { logTokenUsage } from "../lib/token-usage";
|
|
@@ -173,7 +187,7 @@ function handleRating(
|
|
|
173
187
|
|
|
174
188
|
// ── Implicit Rating ──
|
|
175
189
|
|
|
176
|
-
function handleImplicitReaction(message: string, sessionId?: string): void {
|
|
190
|
+
function handleImplicitReaction(message: string, sentAt: Date, sessionId?: string): void {
|
|
177
191
|
const trimmed = message.trim();
|
|
178
192
|
const reply = replyEnd(getLastResponse(sessionId));
|
|
179
193
|
|
|
@@ -199,6 +213,7 @@ function handleImplicitReaction(message: string, sessionId?: string): void {
|
|
|
199
213
|
sessionId ?? "",
|
|
200
214
|
Buffer.from(trimmed).toString("base64"),
|
|
201
215
|
Buffer.from(reply).toString("base64"),
|
|
216
|
+
sentAt.toISOString(),
|
|
202
217
|
],
|
|
203
218
|
"rating"
|
|
204
219
|
);
|
|
@@ -210,11 +225,33 @@ async function labelReaction(reply: string, message: string, sessionId?: string)
|
|
|
210
225
|
return result.success ? parseReactionLabel(result.output) : null;
|
|
211
226
|
}
|
|
212
227
|
|
|
228
|
+
function knownRules() {
|
|
229
|
+
const waiting = readCandidates().filter((candidate) => candidate.verdict === "waiting");
|
|
230
|
+
return [
|
|
231
|
+
...readRules(),
|
|
232
|
+
...waiting.map((candidate) => ({ ...candidate, status: "candidate" })),
|
|
233
|
+
];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function draftRuleCandidate(sessionId?: string): Promise<void> {
|
|
237
|
+
const turns = withRequests(readTurns());
|
|
238
|
+
reproveWaiting(turns);
|
|
239
|
+
const corrections = correctionsToDraftFrom(turns);
|
|
240
|
+
const known = knownRules();
|
|
241
|
+
if (!canRepeat(corrections) && !canWiden(corrections, known)) return;
|
|
242
|
+
const result = await inference(drafterRequest(corrections, known, sessionId));
|
|
243
|
+
if (result.usage) logTokenUsage("rule-drafter", result.usage);
|
|
244
|
+
const candidate = result.success ? parseDraft(result.output, corrections, known) : null;
|
|
245
|
+
if (candidate) recordCandidate(candidate, turns);
|
|
246
|
+
else logDebug("rule-drafter", `no candidate: ${result.output ?? result.error ?? ""}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
213
249
|
/** Background mode: label the reaction, confirm a correction, store the rating. */
|
|
214
250
|
async function runReactionRatingAndStore(
|
|
215
251
|
message: string,
|
|
216
252
|
reply: string,
|
|
217
|
-
sessionId
|
|
253
|
+
sessionId: string | undefined,
|
|
254
|
+
sentAt: Date
|
|
218
255
|
): Promise<void> {
|
|
219
256
|
try {
|
|
220
257
|
const first = await labelReaction(reply, message, sessionId);
|
|
@@ -230,7 +267,8 @@ async function runReactionRatingAndStore(
|
|
|
230
267
|
first,
|
|
231
268
|
confirmation
|
|
232
269
|
);
|
|
233
|
-
if (turn) appendTurn(turn);
|
|
270
|
+
if (turn) appendTurn(turn, sentAt);
|
|
271
|
+
if (turn?.confirmed) await draftRuleCandidate(sessionId);
|
|
234
272
|
} catch (err) {
|
|
235
273
|
const { logError } = await import("../lib/log");
|
|
236
274
|
logError("rating:reaction-child", err);
|
|
@@ -239,7 +277,11 @@ async function runReactionRatingAndStore(
|
|
|
239
277
|
|
|
240
278
|
// ── Main Export ──
|
|
241
279
|
|
|
242
|
-
export function captureRating(
|
|
280
|
+
export function captureRating(
|
|
281
|
+
message: string,
|
|
282
|
+
sessionId?: string,
|
|
283
|
+
sentAt: Date = new Date()
|
|
284
|
+
): void {
|
|
243
285
|
// Strip IDE/system-injected tags to recover raw user text
|
|
244
286
|
const cleaned = stripInjectedTags(message);
|
|
245
287
|
|
|
@@ -258,7 +300,7 @@ export function captureRating(message: string, sessionId?: string): void {
|
|
|
258
300
|
|
|
259
301
|
// Path 2: Implicit reaction — the praise fast-path runs synchronously, the
|
|
260
302
|
// model path detaches to a background bun subprocess (mirrors session-name).
|
|
261
|
-
handleImplicitReaction(cleaned, sessionId);
|
|
303
|
+
handleImplicitReaction(cleaned, sentAt, sessionId);
|
|
262
304
|
}
|
|
263
305
|
|
|
264
306
|
// Background reaction entry point
|
|
@@ -268,10 +310,12 @@ if (process.argv[2] === "--sentiment") {
|
|
|
268
310
|
const replyB64 = process.argv[5];
|
|
269
311
|
if (msgB64 && replyB64) {
|
|
270
312
|
const decode = (b64: string) => Buffer.from(b64, "base64").toString("utf-8");
|
|
313
|
+
const sentAt = new Date(process.argv[6] ?? "");
|
|
271
314
|
await runReactionRatingAndStore(
|
|
272
315
|
decode(msgB64),
|
|
273
316
|
decode(replyB64),
|
|
274
|
-
sid === "" ? undefined : sid
|
|
317
|
+
sid === "" ? undefined : sid,
|
|
318
|
+
Number.isNaN(sentAt.getTime()) ? new Date() : sentAt
|
|
275
319
|
);
|
|
276
320
|
}
|
|
277
321
|
process.exit(0);
|
|
@@ -0,0 +1,95 @@
|
|
|
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 {
|
|
15
|
+
type ProofVerdict,
|
|
16
|
+
proofVerdict,
|
|
17
|
+
proveTrigger,
|
|
18
|
+
widenedProofVerdict,
|
|
19
|
+
} from "./rule-proof";
|
|
20
|
+
|
|
21
|
+
export interface CandidateInput extends DraftInput {
|
|
22
|
+
check: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface Candidate extends CandidateInput {
|
|
26
|
+
id: string;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
verdict: ProofVerdict;
|
|
29
|
+
proof: TriggerProof;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function candidatesPath(): string {
|
|
33
|
+
return resolve(paths.adaptation(), "candidates.jsonl");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseLine(line: string): Candidate | null {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(line) as Candidate;
|
|
39
|
+
return typeof parsed.createdAt === "string" ? parsed : null;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readCandidates(): Candidate[] {
|
|
46
|
+
const path = candidatesPath();
|
|
47
|
+
if (!existsSync(path)) return [];
|
|
48
|
+
return readFileSync(path, "utf-8")
|
|
49
|
+
.split("\n")
|
|
50
|
+
.map(parseLine)
|
|
51
|
+
.filter((candidate): candidate is Candidate => candidate !== null);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function writeCandidates(candidates: Candidate[]): void {
|
|
55
|
+
const path = candidatesPath();
|
|
56
|
+
const pending = `${path}.${process.pid}.tmp`;
|
|
57
|
+
writeFileSync(pending, candidates.map((c) => `${JSON.stringify(c)}\n`).join(""));
|
|
58
|
+
renameSync(pending, path);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function prove(candidate: Candidate, turns: RequestedTurn[]): Candidate {
|
|
62
|
+
const proof = proveTrigger(candidate.trigger, turns);
|
|
63
|
+
const verdict = candidate.widens ? widenedProofVerdict(proof) : proofVerdict(proof);
|
|
64
|
+
return { ...candidate, proof, verdict };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function promoteIfPassed(candidate: Candidate): void {
|
|
68
|
+
if (candidate.verdict !== "passed") return;
|
|
69
|
+
const { id: _id, createdAt: _createdAt, verdict: _verdict, ...draft } = candidate;
|
|
70
|
+
addDraft(draft);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function recordCandidate(
|
|
74
|
+
input: CandidateInput,
|
|
75
|
+
turns: RequestedTurn[],
|
|
76
|
+
now: Date = new Date()
|
|
77
|
+
): Candidate {
|
|
78
|
+
const fresh = {
|
|
79
|
+
...input,
|
|
80
|
+
id: randomUUID().slice(0, 8),
|
|
81
|
+
createdAt: now.toISOString(),
|
|
82
|
+
} as Candidate;
|
|
83
|
+
const candidate = prove(fresh, turns);
|
|
84
|
+
writeCandidates([...readCandidates(), candidate]);
|
|
85
|
+
promoteIfPassed(candidate);
|
|
86
|
+
return candidate;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function reproveWaiting(turns: RequestedTurn[]): void {
|
|
90
|
+
const candidates = readCandidates();
|
|
91
|
+
if (!candidates.some((c) => c.verdict === "waiting")) return;
|
|
92
|
+
const updated = candidates.map((c) => (c.verdict === "waiting" ? prove(c, turns) : c));
|
|
93
|
+
writeCandidates(updated);
|
|
94
|
+
updated.filter((c, i) => candidates[i].verdict === "waiting").forEach(promoteIfPassed);
|
|
95
|
+
}
|
|
@@ -12,17 +12,30 @@ 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;
|
|
37
|
+
/** The approved rule whose trigger this draft replaces with a wider one. */
|
|
38
|
+
widens?: string;
|
|
26
39
|
}
|
|
27
40
|
|
|
28
41
|
export interface AdaptationRule extends DraftInput {
|
|
@@ -67,12 +80,34 @@ export function addDraft(input: DraftInput): AdaptationRule {
|
|
|
67
80
|
return rule;
|
|
68
81
|
}
|
|
69
82
|
|
|
83
|
+
function approveWidening(
|
|
84
|
+
rules: AdaptationRule[],
|
|
85
|
+
widening: AdaptationRule
|
|
86
|
+
): DecideResult {
|
|
87
|
+
const target = rules.find((r) => r.id === widening.widens);
|
|
88
|
+
if (target?.status !== "approved")
|
|
89
|
+
return { ok: false, reason: `Rule ${widening.widens} is not an approved rule` };
|
|
90
|
+
const widened: AdaptationRule = {
|
|
91
|
+
...target,
|
|
92
|
+
trigger: widening.trigger,
|
|
93
|
+
evidence: [...target.evidence, ...widening.evidence],
|
|
94
|
+
proof: widening.proof,
|
|
95
|
+
};
|
|
96
|
+
writeRules(
|
|
97
|
+
rules
|
|
98
|
+
.filter((r) => r.id !== widening.id)
|
|
99
|
+
.map((r) => (r.id === target.id ? widened : r))
|
|
100
|
+
);
|
|
101
|
+
return { ok: true, rule: widened };
|
|
102
|
+
}
|
|
103
|
+
|
|
70
104
|
export function decideRule(id: string, decision: Decision): DecideResult {
|
|
71
105
|
const rules = readRules();
|
|
72
106
|
const rule = rules.find((r) => r.id === id);
|
|
73
107
|
if (!rule) return { ok: false, reason: `No rule with id ${id}` };
|
|
74
108
|
if (rule.status !== "draft")
|
|
75
109
|
return { ok: false, reason: `Rule ${id} was already ${rule.status}` };
|
|
110
|
+
if (rule.widens && decision === "approved") return approveWidening(rules, rule);
|
|
76
111
|
const decided: AdaptationRule = {
|
|
77
112
|
...rule,
|
|
78
113
|
status: decision,
|
|
@@ -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
|
|