portable-agent-layer 0.85.5 → 0.85.7
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 +8 -0
- package/assets/templates/pal-settings.json +2 -0
- package/package.json +1 -1
- package/src/hooks/StopOrchestrator.ts +14 -13
- package/src/hooks/lib/agent.ts +7 -0
- package/src/hooks/lib/claim-check.ts +122 -0
- package/src/hooks/lib/claim-log.ts +131 -0
- package/src/hooks/lib/interaction-preferences.ts +5 -1
- package/src/hooks/lib/interaction-reaction.ts +26 -4
- package/src/hooks/lib/interaction-samples.ts +1 -0
- package/src/hooks/lib/interaction.ts +3 -0
- package/src/hooks/lib/stop.ts +2 -0
- package/src/hooks/lib/transcript.ts +1 -1
- package/src/tools/agent/interaction.ts +10 -4
- package/src/tools/lib/interaction-report.ts +37 -4
|
@@ -82,6 +82,14 @@
|
|
|
82
82
|
"type": "boolean",
|
|
83
83
|
"description": "Keep a rolling local sample (about 230 messages) of user message text, its reaction label and the end of the reply it answered, in memory/state/reaction-samples.json, so the reaction rules can be audited. Stores message text; set false to keep none."
|
|
84
84
|
},
|
|
85
|
+
"claimCheck": {
|
|
86
|
+
"type": "boolean",
|
|
87
|
+
"description": "At each stop, check whether the final reply claims a result (tests pass, CI green, works now) that no command in that turn could have shown, and log the verdict to memory/signals/claim-checks/. Stores the claiming sentence only when nothing backed it."
|
|
88
|
+
},
|
|
89
|
+
"claimCheckBlocks": {
|
|
90
|
+
"type": "boolean",
|
|
91
|
+
"description": "Opt-in, off by default. Send a reply with an unbacked result 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."
|
|
92
|
+
},
|
|
85
93
|
"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." }
|
|
86
94
|
}
|
|
87
95
|
},
|
package/package.json
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
9
|
-
import {
|
|
9
|
+
import { stopBlockResponse } from "./lib/agent";
|
|
10
|
+
import { watchClaims } from "./lib/claim-log";
|
|
10
11
|
import { duplicatesCursorHooks } from "./lib/cursor-shadow";
|
|
11
12
|
import { logError } from "./lib/log";
|
|
12
13
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
@@ -24,21 +25,21 @@ try {
|
|
|
24
25
|
// is worth less than not blocking at all — require the reason to raise one.
|
|
25
26
|
const decision = checkReadmeSync();
|
|
26
27
|
if (decision.decision === "block" && decision.reason) {
|
|
27
|
-
|
|
28
|
-
// Cursor stop hook: followup_message auto-sends to the agent
|
|
29
|
-
process.stdout.write(JSON.stringify({ followup_message: decision.reason }));
|
|
30
|
-
} else if (isCodex()) {
|
|
31
|
-
// Codex stop hook: additionalContext re-queues as next prompt
|
|
32
|
-
process.stdout.write(JSON.stringify({ additionalContext: decision.reason }));
|
|
33
|
-
} else {
|
|
34
|
-
// Claude Code, the Copilot CLI and VS Code's own Copilot each read a
|
|
35
|
-
// different stop-block shape; VS Code ignores the top-level keys entirely.
|
|
36
|
-
process.stdout.write(blockResponse(decision.reason, "Stop"));
|
|
37
|
-
}
|
|
28
|
+
process.stdout.write(stopBlockResponse(decision.reason));
|
|
38
29
|
process.exit(0);
|
|
39
30
|
}
|
|
40
31
|
} catch (err) {
|
|
41
32
|
logError("StopOrchestrator:readme-sync", err);
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
|
|
35
|
+
const payload = await readStdinJSON<StopTurnPayload>();
|
|
36
|
+
try {
|
|
37
|
+
const sendBack = watchClaims(payload);
|
|
38
|
+
if (sendBack) {
|
|
39
|
+
process.stdout.write(stopBlockResponse(sendBack));
|
|
40
|
+
process.exit(0);
|
|
41
|
+
}
|
|
42
|
+
} catch (err) {
|
|
43
|
+
logError("StopOrchestrator:claim-check", err);
|
|
44
|
+
}
|
|
45
|
+
await stopTurn(payload);
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -267,6 +267,13 @@ export function promptContextResponse(context: string): string | null {
|
|
|
267
267
|
return `${context}\n`;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/** Stops the agent from ending its turn and hands it the reason as its next input. */
|
|
271
|
+
export function stopBlockResponse(reason: string): string {
|
|
272
|
+
if (isCursor()) return JSON.stringify({ followup_message: reason });
|
|
273
|
+
if (isCodex()) return JSON.stringify({ additionalContext: reason });
|
|
274
|
+
return blockResponse(reason, "Stop");
|
|
275
|
+
}
|
|
276
|
+
|
|
270
277
|
export function blockResponse(reason: string, hookEventName?: string): string {
|
|
271
278
|
if (isCursor()) {
|
|
272
279
|
return JSON.stringify({ permission: "deny", user_message: reason });
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a reply claims a result (tests pass, CI green, it works now) that no
|
|
3
|
+
* command in the same turn could have shown. Precision over recall: a hedged or
|
|
4
|
+
* negated sentence is never a claim, and an unreadable turn is never unbacked.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isSystemText } from "./prompt-text";
|
|
8
|
+
import { isCodexInjectedContext } from "./transcript";
|
|
9
|
+
|
|
10
|
+
const CLAIM_RE =
|
|
11
|
+
/\b(?:tests?|suite|ci|checks?|builds?|gates?|pipeline)\b.*\b(?:pass(?:es|ed)?|green|succeed(?:s|ed)?)\b|\bverified\b|\b(?:works|working) now\b|\bnow works\b/i;
|
|
12
|
+
const HEDGED_RE =
|
|
13
|
+
/\b(?:not|no longer|fail(?:s|ed|ing)?|until|when|if|unless|should|would|will|might|could|may)\b|n't\b/i;
|
|
14
|
+
|
|
15
|
+
const COMMAND_TOOLS = new Set([
|
|
16
|
+
"Bash",
|
|
17
|
+
"BashOutput",
|
|
18
|
+
"Monitor",
|
|
19
|
+
"PowerShell",
|
|
20
|
+
"exec",
|
|
21
|
+
"shell",
|
|
22
|
+
"exec_command",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
export type ClaimVerdict = "none" | "backed" | "unbacked" | "unknown";
|
|
26
|
+
|
|
27
|
+
export interface ClaimCheck {
|
|
28
|
+
verdict: ClaimVerdict;
|
|
29
|
+
claims: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sentences(reply: string): string[] {
|
|
33
|
+
return reply
|
|
34
|
+
.split(/(?<=[.!?])\s+|\n+/)
|
|
35
|
+
.map((s) => s.trim().replace(/[.!?]+$/, ""))
|
|
36
|
+
.filter(Boolean);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A quoted claim is being talked about, not made. */
|
|
40
|
+
function withoutQuotes(sentence: string): string {
|
|
41
|
+
return sentence.replace(/"[^"]*"|“[^”]*”|`[^`]*`/g, "");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resultClaims(reply: string): string[] {
|
|
45
|
+
return sentences(reply).filter((s) => {
|
|
46
|
+
const own = withoutQuotes(s);
|
|
47
|
+
return CLAIM_RE.test(own) && !HEDGED_RE.test(own);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TranscriptLine {
|
|
52
|
+
type?: string;
|
|
53
|
+
message?: { content?: unknown };
|
|
54
|
+
payload?: { type?: string; role?: string; name?: string; content?: unknown };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parsed(line: string): TranscriptLine | null {
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function blocks(content: unknown): { type?: string; name?: string; text?: string }[] {
|
|
66
|
+
return Array.isArray(content) ? content : [];
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function promptText(entry: TranscriptLine): string | null {
|
|
70
|
+
if (entry.type === "user") {
|
|
71
|
+
const content = entry.message?.content;
|
|
72
|
+
if (typeof content === "string") return content;
|
|
73
|
+
const texts = blocks(content).filter((b) => b.type === "text");
|
|
74
|
+
return texts.length ? texts.map((b) => b.text ?? "").join(" ") : null;
|
|
75
|
+
}
|
|
76
|
+
if (entry.type === "response_item" && entry.payload?.role === "user") {
|
|
77
|
+
return blocks(entry.payload.content)
|
|
78
|
+
.map((b) => b.text ?? "")
|
|
79
|
+
.join(" ");
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isUserPrompt(entry: TranscriptLine): boolean {
|
|
85
|
+
const text = promptText(entry);
|
|
86
|
+
return text !== null && !isSystemText(text) && !isCodexInjectedContext(text);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function commandsIn(entry: TranscriptLine): number {
|
|
90
|
+
if (entry.type === "assistant")
|
|
91
|
+
return blocks(entry.message?.content).filter(
|
|
92
|
+
(b) => b.type === "tool_use" && COMMAND_TOOLS.has(b.name ?? "")
|
|
93
|
+
).length;
|
|
94
|
+
const call = entry.payload;
|
|
95
|
+
if (entry.type !== "response_item" || !call) return 0;
|
|
96
|
+
if (call.type === "local_shell_call") return 1;
|
|
97
|
+
const isCall = call.type === "function_call" || call.type === "custom_tool_call";
|
|
98
|
+
return isCall && COMMAND_TOOLS.has(call.name ?? "") ? 1 : 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isTurnEntry(entry: TranscriptLine): boolean {
|
|
102
|
+
return ["user", "assistant", "response_item"].includes(entry.type ?? "");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Null when no line reads as a turn, so an unknown transcript format never counts as zero. */
|
|
106
|
+
export function commandsThisTurn(lines: string[]): number | null {
|
|
107
|
+
const entries = lines.map(parsed).filter((e): e is TranscriptLine => e !== null);
|
|
108
|
+
if (!entries.some(isTurnEntry)) return null;
|
|
109
|
+
let commands = 0;
|
|
110
|
+
for (const entry of entries) {
|
|
111
|
+
if (isUserPrompt(entry)) commands = 0;
|
|
112
|
+
commands += commandsIn(entry);
|
|
113
|
+
}
|
|
114
|
+
return commands;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function checkClaims(reply: string, commands: number | null): ClaimCheck {
|
|
118
|
+
const claims = resultClaims(reply);
|
|
119
|
+
if (claims.length === 0) return { verdict: "none", claims };
|
|
120
|
+
if (commands === null) return { verdict: "unknown", claims };
|
|
121
|
+
return { verdict: commands > 0 ? "backed" : "unbacked", claims };
|
|
122
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The claim check at stop: every reply that makes a result claim is logged to
|
|
3
|
+
* memory/signals/claim-checks/ with its verdict. The claiming sentence is kept
|
|
4
|
+
* only when no command backed it, so the log can be audited. Sending the reply
|
|
5
|
+
* back is opt-in (dynamicContext.claimCheckBlocks).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
import { currentAttribution } from "./actor";
|
|
11
|
+
import {
|
|
12
|
+
type ClaimCheck,
|
|
13
|
+
type ClaimVerdict,
|
|
14
|
+
checkClaims,
|
|
15
|
+
commandsThisTurn,
|
|
16
|
+
} from "./claim-check";
|
|
17
|
+
import { hookFinalReply, hookSessionId } from "./hook-turn";
|
|
18
|
+
import { ensureDir, paths } from "./paths";
|
|
19
|
+
import { isEnabled, isOptedIn } from "./settings";
|
|
20
|
+
import type { StopTurnPayload } from "./stop";
|
|
21
|
+
|
|
22
|
+
const CLAIM_MAX = 200;
|
|
23
|
+
|
|
24
|
+
export interface ClaimRecord {
|
|
25
|
+
ts: string;
|
|
26
|
+
session: string;
|
|
27
|
+
runtime?: string;
|
|
28
|
+
verdict: ClaimVerdict;
|
|
29
|
+
commands: number | null;
|
|
30
|
+
claims?: string[];
|
|
31
|
+
blocked?: true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function claimsDir(): string {
|
|
35
|
+
return ensureDir(resolve(paths.signals(), "claim-checks"));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function monthFile(date: Date): string {
|
|
39
|
+
return resolve(claimsDir(), `${date.toISOString().slice(0, 7)}.jsonl`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function transcriptLines(payload: StopTurnPayload): string[] {
|
|
43
|
+
const path = payload.transcript_path ?? payload.transcriptPath;
|
|
44
|
+
if (!path || !existsSync(path)) return [];
|
|
45
|
+
return readFileSync(path, "utf-8").split("\n").filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function needsAudit(verdict: ClaimVerdict): boolean {
|
|
49
|
+
return verdict === "unbacked" || verdict === "unknown";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Watched {
|
|
53
|
+
check: ClaimCheck;
|
|
54
|
+
commands: number | null;
|
|
55
|
+
session: string;
|
|
56
|
+
blocked: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function record({ check, commands, session, blocked }: Watched, now: Date) {
|
|
60
|
+
const line: ClaimRecord = {
|
|
61
|
+
ts: now.toISOString(),
|
|
62
|
+
session,
|
|
63
|
+
runtime: currentAttribution().runtime,
|
|
64
|
+
verdict: check.verdict,
|
|
65
|
+
commands,
|
|
66
|
+
...(needsAudit(check.verdict) && {
|
|
67
|
+
claims: check.claims.map((c) => c.slice(0, CLAIM_MAX)),
|
|
68
|
+
}),
|
|
69
|
+
...(blocked && { blocked }),
|
|
70
|
+
};
|
|
71
|
+
appendFileSync(monthFile(now), `${JSON.stringify(line)}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function alreadySentBack(payload: StopTurnPayload): boolean {
|
|
75
|
+
return payload.stop_hook_active === true || (payload.loop_count ?? 0) > 0;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sendsBack(payload: StopTurnPayload, check: ClaimCheck): boolean {
|
|
79
|
+
return (
|
|
80
|
+
check.verdict === "unbacked" &&
|
|
81
|
+
isOptedIn("claimCheckBlocks") &&
|
|
82
|
+
!alreadySentBack(payload)
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sendBackReason(claims: string[]): string {
|
|
87
|
+
const quoted = claims.map((c) => `"${c.slice(0, CLAIM_MAX)}"`).join(", ");
|
|
88
|
+
return `Your reply claims a result no command in this turn showed: ${quoted}. Run the check and show its output, or say it is unverified.`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Checks the final reply against the turn's commands and logs any result claim.
|
|
93
|
+
* Returns the reason to send the reply back with, when that is opted in.
|
|
94
|
+
*/
|
|
95
|
+
export function watchClaims(
|
|
96
|
+
payload: StopTurnPayload | null,
|
|
97
|
+
now: Date = new Date()
|
|
98
|
+
): string | null {
|
|
99
|
+
if (!payload || !isEnabled("claimCheck")) return null;
|
|
100
|
+
const reply = hookFinalReply(payload);
|
|
101
|
+
if (!reply) return null;
|
|
102
|
+
const commands = commandsThisTurn(transcriptLines(payload));
|
|
103
|
+
const check = checkClaims(reply, commands);
|
|
104
|
+
if (check.verdict === "none") return null;
|
|
105
|
+
const blocked = sendsBack(payload, check);
|
|
106
|
+
record({ check, commands, session: hookSessionId(payload) ?? "unknown", blocked }, now);
|
|
107
|
+
return blocked ? sendBackReason(check.claims) : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readRecords(file: string): ClaimRecord[] {
|
|
111
|
+
return readFileSync(file, "utf-8")
|
|
112
|
+
.split("\n")
|
|
113
|
+
.filter(Boolean)
|
|
114
|
+
.flatMap((line) => {
|
|
115
|
+
try {
|
|
116
|
+
return [JSON.parse(line) as ClaimRecord];
|
|
117
|
+
} catch {
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function claimChecksSince(since: Date): ClaimRecord[] {
|
|
124
|
+
const fromMonth = since.toISOString().slice(0, 7);
|
|
125
|
+
const sinceTs = since.toISOString();
|
|
126
|
+
return readdirSync(claimsDir())
|
|
127
|
+
.filter((f) => f.endsWith(".jsonl") && f.slice(0, 7) >= fromMonth)
|
|
128
|
+
.sort()
|
|
129
|
+
.flatMap((f) => readRecords(resolve(claimsDir(), f)))
|
|
130
|
+
.filter((r) => r.ts >= sinceTs);
|
|
131
|
+
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { median } from "./interaction-mood";
|
|
9
|
+
import { approvalIsKnown } from "./interaction-reaction";
|
|
9
10
|
|
|
10
11
|
const MIN_REPLIES_PER_SIDE = 20;
|
|
11
12
|
const MIN_GAP = 0.1;
|
|
@@ -20,6 +21,7 @@ interface ReplyFeatures {
|
|
|
20
21
|
interface ReactedTurn {
|
|
21
22
|
reply: ReplyFeatures | null;
|
|
22
23
|
reaction: string | null;
|
|
24
|
+
reactionRules?: number;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
interface Outcome {
|
|
@@ -41,7 +43,9 @@ export interface Split {
|
|
|
41
43
|
|
|
42
44
|
function outcomes(events: ReactedTurn[]): Outcome[] {
|
|
43
45
|
return events.flatMap((e) =>
|
|
44
|
-
e.reply && e.reaction
|
|
46
|
+
e.reply && e.reaction && approvalIsKnown(e)
|
|
47
|
+
? [{ reply: e.reply, approved: e.reaction === "approved" }]
|
|
48
|
+
: []
|
|
45
49
|
);
|
|
46
50
|
}
|
|
47
51
|
|
|
@@ -6,7 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
import { extractKeywords, similarity } from "./text-similarity";
|
|
8
8
|
|
|
9
|
-
export type Reaction =
|
|
9
|
+
export type Reaction =
|
|
10
|
+
| "corrected"
|
|
11
|
+
| "repeated"
|
|
12
|
+
| "approved"
|
|
13
|
+
| "go-ahead"
|
|
14
|
+
| "new-topic"
|
|
15
|
+
| "follow-up";
|
|
16
|
+
|
|
17
|
+
/** Logged with each reaction, so a log read under older rules is not read with today's meaning. */
|
|
18
|
+
export const REACTION_RULES = 2;
|
|
19
|
+
const GO_AHEAD_SPLIT = 2;
|
|
20
|
+
|
|
21
|
+
export function approvalIsKnown(turn: { reactionRules?: number }): boolean {
|
|
22
|
+
return (turn.reactionRules ?? 1) >= GO_AHEAD_SPLIT;
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
const CORRECTION_RE =
|
|
12
26
|
/^(?:(?:no|nope|wrong)(?:[,.!]|$)|(?:that'?s|this is|it'?s) (?:wrong|not)\b|not what i\b|i said\b|i told you\b|you forgot\b|you missed\b|still (?:broken|failing|wrong)\b|i don'?t see (?:the|your) (?!(?:problem|issue|point|harm|difference|need|reason)\b))/i;
|
|
@@ -15,7 +29,10 @@ const CLAIM_DISPUTED_RE =
|
|
|
15
29
|
/\byou(?:'re| are) (?:partially |partly |completely |totally )?wrong\b|\b(?:isn'?t|wasn'?t|aren'?t|weren'?t) (?:added|included|fixed|pushed|committed|applied|merged)\b/i;
|
|
16
30
|
|
|
17
31
|
const APPROVAL_RE =
|
|
18
|
-
/^(?:(?:understood|got it|ok(?:ay)?),?\s+)?(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that)
|
|
32
|
+
/^(?:(?:understood|got it|ok(?:ay)?),?\s+)?(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|it works|thanks|thank you|thx|y+e+s+|yep|yeah|yup|sure|exactly|correct|agreed|lgtm|well done|love it|sounds good|looks good|that'?s it|(?:it|this|that)(?: is|'s) (?:fine|good|great|right))\b/i;
|
|
33
|
+
|
|
34
|
+
const PRAISE_RE =
|
|
35
|
+
/\b(?:good|great|nice|perfect|cool|amazing|awesome|excellent|brilliant|works|thanks|thank you|thx|exactly|correct|agreed|lgtm|well done|good job|love it|sounds good|looks (?:good|right)|that'?s it|(?:it|this|that)(?: is|'s) (?:fine|good|great|right))\b/i;
|
|
19
36
|
|
|
20
37
|
const GO_AHEAD_RE =
|
|
21
38
|
/^(?:go ahead|go|do it|proceed|continue|merge|push|ship|commit|build|start|fix (?:all|them|both)|approved|let'?s (?:see|do|go|build|start|fix|ship)|(?:add|do|fix) (?:it )?(?:pls|please))\b/i;
|
|
@@ -51,7 +68,7 @@ function sentences(text: string): string[] {
|
|
|
51
68
|
.filter(Boolean);
|
|
52
69
|
}
|
|
53
70
|
|
|
54
|
-
function
|
|
71
|
+
function isAcceptance(text: string): boolean {
|
|
55
72
|
const trimmed = text.trim();
|
|
56
73
|
if (trimmed.includes("?") || FAILURE_RE.test(trimmed)) return false;
|
|
57
74
|
if (HEDGE_RE.test(trimmed.split(/[.!?\n]/)[0])) return false;
|
|
@@ -61,6 +78,11 @@ function isApproval(text: string): boolean {
|
|
|
61
78
|
return isShortGoAhead(all[0] ?? "") && !HEDGE_RE.test(trimmed);
|
|
62
79
|
}
|
|
63
80
|
|
|
81
|
+
/** A yes or a go only gives permission to continue; approval judges the work itself. */
|
|
82
|
+
function praisesTheResult(text: string): boolean {
|
|
83
|
+
return PRAISE_RE.test(text);
|
|
84
|
+
}
|
|
85
|
+
|
|
64
86
|
/** Word overlap was measured against real messages and does not separate topics; only saying so does. */
|
|
65
87
|
function isNewTopic(text: string): boolean {
|
|
66
88
|
return NEW_TOPIC_RE.test(text.trim());
|
|
@@ -69,7 +91,7 @@ function isNewTopic(text: string): boolean {
|
|
|
69
91
|
export function reactionTo(text: string, previousPrompt: string | undefined): Reaction {
|
|
70
92
|
if (isCorrection(text)) return "corrected";
|
|
71
93
|
if (isRepeat(text, previousPrompt)) return "repeated";
|
|
72
|
-
if (
|
|
94
|
+
if (isAcceptance(text)) return praisesTheResult(text) ? "approved" : "go-ahead";
|
|
73
95
|
if (isNewTopic(text)) return "new-topic";
|
|
74
96
|
return "follow-up";
|
|
75
97
|
}
|
|
@@ -26,6 +26,7 @@ import { shorterApprovedMore } from "./interaction-preferences";
|
|
|
26
26
|
import {
|
|
27
27
|
isCorrection,
|
|
28
28
|
isRepeat,
|
|
29
|
+
REACTION_RULES,
|
|
29
30
|
type Reaction,
|
|
30
31
|
reactionTo,
|
|
31
32
|
} from "./interaction-reaction";
|
|
@@ -51,6 +52,7 @@ export interface TurnEvent extends MoodTurn {
|
|
|
51
52
|
weekday: string;
|
|
52
53
|
reply: Omit<ReplyShape, "at"> | null;
|
|
53
54
|
reaction: Reaction | null;
|
|
55
|
+
reactionRules?: number;
|
|
54
56
|
mood?: string;
|
|
55
57
|
hinted?: boolean;
|
|
56
58
|
complied?: boolean;
|
|
@@ -129,6 +131,7 @@ function measureTurn(
|
|
|
129
131
|
corrected: isCorrection(text),
|
|
130
132
|
reply: reply ? replyFeatures(reply) : null,
|
|
131
133
|
reaction: reply ? reactionTo(text, track.lastPrompt) : null,
|
|
134
|
+
...(reply && { reactionRules: REACTION_RULES }),
|
|
132
135
|
};
|
|
133
136
|
}
|
|
134
137
|
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
export interface StopTurnPayload extends HookTurnPayload {
|
|
42
42
|
transcript_path?: string | null;
|
|
43
43
|
transcriptPath?: string | null;
|
|
44
|
+
stop_hook_active?: boolean;
|
|
45
|
+
loop_count?: number;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
type Transcript = ReturnType<typeof readTranscriptFile>;
|
|
@@ -38,7 +38,7 @@ interface CodexPayload {
|
|
|
38
38
|
|
|
39
39
|
const CODEX_INJECTED_CONTEXT = ["# AGENTS.md instructions", "<environment_context>"];
|
|
40
40
|
|
|
41
|
-
function isCodexInjectedContext(text: string): boolean {
|
|
41
|
+
export function isCodexInjectedContext(text: string): boolean {
|
|
42
42
|
return CODEX_INJECTED_CONTEXT.some((prefix) => text.startsWith(prefix));
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
|
+
import { claimChecksSince } from "../../hooks/lib/claim-log";
|
|
10
11
|
import { turnsSince } from "../../hooks/lib/interaction";
|
|
11
|
-
import { reportLines, summarize } from "../lib/interaction-report";
|
|
12
|
+
import { claimCheckLines, reportLines, summarize } from "../lib/interaction-report";
|
|
12
13
|
import { scriptArgs } from "../lib/script-args";
|
|
13
14
|
|
|
14
15
|
const HELP = `
|
|
@@ -18,7 +19,9 @@ const HELP = `
|
|
|
18
19
|
text) and prints turns, channels, reactions, the hints sent, per agent the
|
|
19
20
|
turns, replies filed and hints sent, and for each
|
|
20
21
|
label (short, long, fast, skimming, friction) how the replies written while
|
|
21
|
-
it was active compare with the replies written while none was.
|
|
22
|
+
it was active compare with the replies written while none was. Last, the
|
|
23
|
+
replies that claimed a result (memory/signals/claim-checks/), and which of
|
|
24
|
+
them had no command behind the claim or were sent back.
|
|
22
25
|
|
|
23
26
|
Usage: pal cli interaction report [--days N] (default 7)
|
|
24
27
|
`;
|
|
@@ -39,8 +42,11 @@ export function run(argv: string[] = scriptArgs()) {
|
|
|
39
42
|
console.log(HELP);
|
|
40
43
|
return;
|
|
41
44
|
}
|
|
42
|
-
const
|
|
43
|
-
|
|
45
|
+
const since = new Date(Date.now() - days * DAY_MS);
|
|
46
|
+
const events = turnsSince(since);
|
|
47
|
+
const claims = claimCheckLines(claimChecksSince(since));
|
|
48
|
+
for (const line of [...reportLines(summarize(events), days), ...claims])
|
|
49
|
+
console.log(line);
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
if (import.meta.main) run();
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* the agent's replies changed while a measured label was in its context.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
+
import type { ClaimRecord } from "../../hooks/lib/claim-log";
|
|
6
7
|
import type { TurnEvent } from "../../hooks/lib/interaction";
|
|
7
8
|
import { median } from "../../hooks/lib/interaction-mood";
|
|
8
9
|
import {
|
|
@@ -14,13 +15,14 @@ import {
|
|
|
14
15
|
type Split,
|
|
15
16
|
splits,
|
|
16
17
|
} from "../../hooks/lib/interaction-preferences";
|
|
18
|
+
import { approvalIsKnown } from "../../hooks/lib/interaction-reaction";
|
|
17
19
|
|
|
18
20
|
const LABELS = ["short", "long", "fast", "skimming", "friction"] as const;
|
|
19
21
|
|
|
20
22
|
interface ReplyUnderMood {
|
|
21
23
|
labels: string[];
|
|
22
24
|
words: number;
|
|
23
|
-
approved: boolean;
|
|
25
|
+
approved: boolean | null;
|
|
24
26
|
corrected: boolean;
|
|
25
27
|
complied?: boolean;
|
|
26
28
|
}
|
|
@@ -52,6 +54,7 @@ export interface InteractionSummary {
|
|
|
52
54
|
reactions: Record<string, number>;
|
|
53
55
|
hints: Record<string, number>;
|
|
54
56
|
unlogged: number;
|
|
57
|
+
readBeforeGoAhead: number;
|
|
55
58
|
usual: ReplyStats;
|
|
56
59
|
byLabel: Record<string, ReplyStats>;
|
|
57
60
|
approvals: Approvals;
|
|
@@ -81,7 +84,10 @@ function stats(replies: ReplyUnderMood[]): ReplyStats {
|
|
|
81
84
|
return {
|
|
82
85
|
replies: replies.length,
|
|
83
86
|
medianWords: median(replies.map((r) => r.words)),
|
|
84
|
-
approvedShare: share(
|
|
87
|
+
approvedShare: share(
|
|
88
|
+
replies.filter((r) => r.approved !== null),
|
|
89
|
+
(r) => r.approved === true
|
|
90
|
+
),
|
|
85
91
|
correctedShare: share(replies, (r) => r.corrected),
|
|
86
92
|
followed: {
|
|
87
93
|
checked: replies.filter((r) => r.complied !== undefined).length,
|
|
@@ -100,7 +106,7 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
|
|
|
100
106
|
replies.push({
|
|
101
107
|
labels: mood ? mood.split(",") : [],
|
|
102
108
|
words: e.reply.words,
|
|
103
|
-
approved: e.reaction === "approved",
|
|
109
|
+
approved: approvalIsKnown(e) ? e.reaction === "approved" : null,
|
|
104
110
|
corrected: e.reaction === "corrected" || e.reaction === "repeated",
|
|
105
111
|
complied: e.complied,
|
|
106
112
|
});
|
|
@@ -109,6 +115,11 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
|
|
|
109
115
|
return replies;
|
|
110
116
|
}
|
|
111
117
|
|
|
118
|
+
function reactionName(e: TurnEvent): string {
|
|
119
|
+
if (e.reaction === "approved" && !approvalIsKnown(e)) return "approved or go-ahead";
|
|
120
|
+
return e.reaction ?? "";
|
|
121
|
+
}
|
|
122
|
+
|
|
112
123
|
/** A turn carries the reply that answered the turn before it, so a filed reply shows up there. */
|
|
113
124
|
function byAgent(events: TurnEvent[]): Record<string, AgentCounts> {
|
|
114
125
|
const agents: Record<string, AgentCounts> = {};
|
|
@@ -131,7 +142,8 @@ export function summarize(events: TurnEvent[]): InteractionSummary {
|
|
|
131
142
|
sessions: new Set(events.map((e) => e.session)).size,
|
|
132
143
|
channels: tally(events.map((e) => e.channel ?? "unknown")),
|
|
133
144
|
agents: byAgent(events),
|
|
134
|
-
reactions: tally(events.flatMap((e) => (e.reaction ? [e
|
|
145
|
+
reactions: tally(events.flatMap((e) => (e.reaction ? [reactionName(e)] : []))),
|
|
146
|
+
readBeforeGoAhead: events.filter((e) => e.reaction && !approvalIsKnown(e)).length,
|
|
135
147
|
hints: tally(hinted.map((e) => e.mood || "back to usual")),
|
|
136
148
|
unlogged: events.filter((e) => e.mood === undefined).length,
|
|
137
149
|
usual: stats(replies.filter((r) => r.labels.length === 0)),
|
|
@@ -187,6 +199,22 @@ function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
|
|
|
187
199
|
return parts.join(" · ");
|
|
188
200
|
}
|
|
189
201
|
|
|
202
|
+
const UNBACKED_LISTED = 5;
|
|
203
|
+
|
|
204
|
+
export function claimCheckLines(records: ClaimRecord[]): string[] {
|
|
205
|
+
if (records.length === 0) return [];
|
|
206
|
+
const unbacked = records.filter((r) => r.verdict === "unbacked");
|
|
207
|
+
const unknown = records.filter((r) => r.verdict === "unknown").length;
|
|
208
|
+
const sentBack = records.filter((r) => r.blocked).length;
|
|
209
|
+
return [
|
|
210
|
+
"",
|
|
211
|
+
`Result claims: ${plural(records.length, "reply", "replies")} · ${unbacked.length} with no command behind it · ${sentBack} sent back · ${unknown} unreadable`,
|
|
212
|
+
...unbacked
|
|
213
|
+
.slice(-UNBACKED_LISTED)
|
|
214
|
+
.flatMap((r) => (r.claims ?? []).map((c) => ` no command: ${c}`)),
|
|
215
|
+
];
|
|
216
|
+
}
|
|
217
|
+
|
|
190
218
|
function plural(n: number, noun: string, nouns = `${noun}s`): string {
|
|
191
219
|
return `${n} ${n === 1 ? noun : nouns}`;
|
|
192
220
|
}
|
|
@@ -255,5 +283,10 @@ export function reportLines(summary: InteractionSummary, days: number): string[]
|
|
|
255
283
|
"",
|
|
256
284
|
`${summary.unlogged} older turns were logged before moods were, and are left out of the comparison.`
|
|
257
285
|
);
|
|
286
|
+
if (summary.readBeforeGoAhead)
|
|
287
|
+
lines.push(
|
|
288
|
+
"",
|
|
289
|
+
`${summary.readBeforeGoAhead} older replies were read before go-ahead was split from approval, and are left out of the approval figures.`
|
|
290
|
+
);
|
|
258
291
|
return lines;
|
|
259
292
|
}
|