portable-agent-layer 0.82.0 → 0.84.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/templates/PAL/MEMORY_SYSTEM.md +1 -1
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +15 -0
- package/assets/templates/hooks.copilot.json +7 -0
- package/assets/templates/hooks.cursor.json +6 -0
- package/package.json +1 -1
- package/src/hooks/AgentResponse.ts +12 -0
- package/src/hooks/PromptTransformed.ts +18 -0
- package/src/hooks/StopOrchestrator.ts +2 -32
- package/src/hooks/UserPromptOrchestrator.ts +3 -5
- package/src/hooks/handlers/inject-retrieval.ts +9 -21
- package/src/hooks/lib/agent.ts +22 -0
- package/src/hooks/lib/hook-turn.ts +36 -0
- package/src/hooks/lib/interaction.ts +7 -1
- package/src/hooks/lib/parked-context.ts +75 -0
- package/src/hooks/lib/stop.ts +27 -0
- package/src/tools/agent/interaction.ts +2 -1
- package/src/tools/lib/interaction-report.ts +39 -2
|
@@ -55,7 +55,7 @@ Almost everything in `state/`, `signals/`, `relationship/opinions.json`, `learni
|
|
|
55
55
|
| A reusable session insight | Session-intelligence handler (automatic) | `learning/session/YYYY-MM/*.md` |
|
|
56
56
|
| A failure worth learning from | Failure-principle handler (automatic, low ratings) | `learning/failures/YYYY-MM/*.md` |
|
|
57
57
|
| A rating signal | UserPromptOrchestrator rating capture | `signals/ratings.jsonl` |
|
|
58
|
-
| How a user turn went (pace, length, skimming, how it received the last reply) | Prompt-time interaction measurement (automatic); read it with `pal cli interaction report` | `signals/interaction/YYYY-MM.jsonl` |
|
|
58
|
+
| How a user turn went (pace, length, skimming, how it received the last reply) | Prompt-time interaction measurement (automatic, every agent; see the per-agent turn contract in SYSTEM_ARCHITECTURE.md); read it with `pal cli interaction report` | `signals/interaction/YYYY-MM.jsonl` |
|
|
59
59
|
| A rolling sample of labelled messages, for auditing the reaction rules | Prompt-time interaction measurement (automatic, `reactionSampling`) | `state/reaction-samples.json` |
|
|
60
60
|
| A project handoff | Project skill / detached handlers | `projects/<slug>/` |
|
|
61
61
|
|
|
@@ -250,6 +250,21 @@ Brief description.
|
|
|
250
250
|
└─────────────────────┘
|
|
251
251
|
```
|
|
252
252
|
|
|
253
|
+
### Per-Agent Turn Contract
|
|
254
|
+
|
|
255
|
+
Every agent's turns are measured and its replies filed under the session id the agent sent. What differs is whether the prompt hook can hand context back, and where the final reply comes from.
|
|
256
|
+
|
|
257
|
+
| Agent | Prompt event → context per turn | Final reply from |
|
|
258
|
+
|-------|---------------------------------|------------------|
|
|
259
|
+
| Claude Code | `UserPromptSubmit` → plain text | `Stop` transcript |
|
|
260
|
+
| Codex | `UserPromptSubmit` → `hookSpecificOutput.additionalContext` | `Stop` `last_assistant_message` |
|
|
261
|
+
| VS Code | `UserPromptSubmit` → `hookSpecificOutput.additionalContext` | `Stop` transcript |
|
|
262
|
+
| opencode | `chat.message` → synthetic part | `session.idle` session messages |
|
|
263
|
+
| Cursor | `beforeSubmitPrompt` → `additional_context`, undocumented | `afterAgentResponse` `text` (`AgentResponse.ts`) |
|
|
264
|
+
| Copilot CLI | `userPromptTransformed` → `modifiedTransformedPrompt` (`PromptTransformed.ts`) | `agentStop` transcript |
|
|
265
|
+
|
|
266
|
+
The Copilot CLI drops what its `userPromptSubmitted` hook returns, so that hook only measures the turn and parks the context; `userPromptTransformed` fires next and appends it to the prompt the model receives. Cursor documents no context field on `beforeSubmitPrompt`, so PAL offers `additional_context` there but logs no interaction hint as sent until it is shown to reach the model; its documented context channel is session start. `test/agent-turns.test.ts` holds each agent to this table.
|
|
267
|
+
|
|
253
268
|
### Design Principles
|
|
254
269
|
|
|
255
270
|
- **Fail-open**: Hook errors never block the user's session
|
|
@@ -15,6 +15,13 @@
|
|
|
15
15
|
"powershell": "bun run {{PKG_ROOT}}/src/hooks/UserPromptOrchestrator.ts --agent=copilot"
|
|
16
16
|
}
|
|
17
17
|
],
|
|
18
|
+
"userPromptTransformed": [
|
|
19
|
+
{
|
|
20
|
+
"type": "command",
|
|
21
|
+
"bash": "bun run {{PKG_ROOT}}/src/hooks/PromptTransformed.ts --agent=copilot",
|
|
22
|
+
"powershell": "bun run {{PKG_ROOT}}/src/hooks/PromptTransformed.ts --agent=copilot"
|
|
23
|
+
}
|
|
24
|
+
],
|
|
18
25
|
"preToolUse": [
|
|
19
26
|
{
|
|
20
27
|
"type": "command",
|
|
@@ -49,6 +49,12 @@
|
|
|
49
49
|
"command": "bun run {{PKG_ROOT}}/src/hooks/SecurityValidator.ts --agent=cursor"
|
|
50
50
|
}
|
|
51
51
|
],
|
|
52
|
+
"afterAgentResponse": [
|
|
53
|
+
{
|
|
54
|
+
"type": "command",
|
|
55
|
+
"command": "bun run {{PKG_ROOT}}/src/hooks/AgentResponse.ts --agent=cursor"
|
|
56
|
+
}
|
|
57
|
+
],
|
|
52
58
|
"stop": [
|
|
53
59
|
{
|
|
54
60
|
"type": "command",
|
package/package.json
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook: afterAgentResponse (Cursor) — files the final reply against the turn it
|
|
3
|
+
* answered. Cursor's stop event carries no reply, this one does.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fileFinalReply, type HookTurnPayload } from "./lib/hook-turn";
|
|
7
|
+
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
8
|
+
import { readStdinJSON } from "./lib/stdin";
|
|
9
|
+
|
|
10
|
+
if (isPalSpawnedInference()) process.exit(0);
|
|
11
|
+
|
|
12
|
+
fileFinalReply(await readStdinJSON<HookTurnPayload>());
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook: userPromptTransformed (Copilot CLI) — appends the context the prompt
|
|
3
|
+
* hook parked to the prompt the model receives.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
type TransformedPromptPayload,
|
|
8
|
+
transformedPromptResponse,
|
|
9
|
+
} from "./lib/parked-context";
|
|
10
|
+
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
11
|
+
import { readStdinJSON } from "./lib/stdin";
|
|
12
|
+
|
|
13
|
+
if (isPalSpawnedInference()) process.exit(0);
|
|
14
|
+
|
|
15
|
+
const response = transformedPromptResponse(
|
|
16
|
+
await readStdinJSON<TransformedPromptPayload>()
|
|
17
|
+
);
|
|
18
|
+
if (response) process.stdout.write(response);
|
|
@@ -3,7 +3,6 @@
|
|
|
3
3
|
* Fans out to independent handlers via Promise.allSettled.
|
|
4
4
|
*
|
|
5
5
|
* stdin: JSON object with { session_id, transcript_path, last_assistant_message, ... }
|
|
6
|
-
* Transcript is read from the file at transcript_path, NOT from stdin.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
@@ -11,22 +10,12 @@ import { blockResponse, isCodex, isCursor } from "./lib/agent";
|
|
|
11
10
|
import { logError } from "./lib/log";
|
|
12
11
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
13
12
|
import { readStdinJSON } from "./lib/stdin";
|
|
14
|
-
import {
|
|
15
|
-
import { readTranscriptFile } from "./lib/transcript";
|
|
13
|
+
import { type StopTurnPayload, stopTurn } from "./lib/stop";
|
|
16
14
|
|
|
17
15
|
// Recursion guard — spawned inference subprocesses must not record session
|
|
18
16
|
// learning, ratings, or handoffs from their throwaway transcript.
|
|
19
17
|
if (isPalSpawnedInference()) process.exit(0);
|
|
20
18
|
|
|
21
|
-
interface StopHookInput {
|
|
22
|
-
session_id?: string;
|
|
23
|
-
sessionId?: string; // Copilot uses camelCase
|
|
24
|
-
transcript_path?: string;
|
|
25
|
-
transcriptPath?: string; // Copilot uses camelCase
|
|
26
|
-
last_assistant_message?: string;
|
|
27
|
-
lastAssistantMessage?: string; // Copilot uses camelCase
|
|
28
|
-
}
|
|
29
|
-
|
|
30
19
|
// Check README sync before anything else — may block the session
|
|
31
20
|
try {
|
|
32
21
|
// A block carrying no reason stops the turn without telling the model why, so it
|
|
@@ -50,23 +39,4 @@ try {
|
|
|
50
39
|
logError("StopOrchestrator:readme-sync", err);
|
|
51
40
|
}
|
|
52
41
|
|
|
53
|
-
|
|
54
|
-
const transcriptPath = input?.transcript_path ?? input?.transcriptPath;
|
|
55
|
-
const sessionId = input?.session_id ?? input?.sessionId;
|
|
56
|
-
const lastAssistant = input?.last_assistant_message ?? input?.lastAssistantMessage;
|
|
57
|
-
|
|
58
|
-
if (!transcriptPath) {
|
|
59
|
-
logError("StopOrchestrator", "No transcript_path in hook input");
|
|
60
|
-
process.exit(0);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Read the actual transcript from the file on disk
|
|
64
|
-
const messages = readTranscriptFile(transcriptPath);
|
|
65
|
-
if (messages.length < 2) process.exit(0);
|
|
66
|
-
|
|
67
|
-
// Serialize and run handlers
|
|
68
|
-
const transcript = JSON.stringify(messages);
|
|
69
|
-
await runStopHandlers(transcript, {
|
|
70
|
-
lastAssistantMessage: lastAssistant,
|
|
71
|
-
sessionId,
|
|
72
|
-
});
|
|
42
|
+
await stopTurn(await readStdinJSON<StopTurnPayload>());
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { injectPromptContext } from "./handlers/inject-retrieval";
|
|
11
11
|
import { captureRating } from "./handlers/rating";
|
|
12
12
|
import { captureSessionName } from "./handlers/session-name";
|
|
13
|
+
import { type HookTurnPayload, hookSessionId } from "./lib/hook-turn";
|
|
13
14
|
import { logDebug, logError, logPromptSnapshot } from "./lib/log";
|
|
14
15
|
import { isPalSpawnedInference } from "./lib/spawn-guard";
|
|
15
16
|
import { readStdinJSON } from "./lib/stdin";
|
|
@@ -18,18 +19,15 @@ import { readStdinJSON } from "./lib/stdin";
|
|
|
18
19
|
// payload, not a real user message. Skip rating capture, session naming, etc.
|
|
19
20
|
if (isPalSpawnedInference()) process.exit(0);
|
|
20
21
|
|
|
21
|
-
interface PromptSubmitInput {
|
|
22
|
+
interface PromptSubmitInput extends HookTurnPayload {
|
|
22
23
|
prompt: string;
|
|
23
|
-
session_id?: string;
|
|
24
|
-
sessionId?: string; // Copilot sends this (camelCase) instead of session_id
|
|
25
|
-
conversation_id?: string; // Cursor sends this instead of session_id
|
|
26
24
|
}
|
|
27
25
|
|
|
28
26
|
const input = await readStdinJSON<PromptSubmitInput>();
|
|
29
27
|
logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)}`);
|
|
30
28
|
if (!input?.prompt) process.exit(0);
|
|
31
29
|
|
|
32
|
-
const sessionId = input
|
|
30
|
+
const sessionId = hookSessionId(input);
|
|
33
31
|
const injected = await injectPromptContext(input.prompt, sessionId);
|
|
34
32
|
logPromptSnapshot(input.prompt, injected);
|
|
35
33
|
|
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
* produces empty output, never blocks the prompt.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import {
|
|
10
|
+
import { promptContextResponse } from "../lib/agent";
|
|
11
11
|
import { observeTurn } from "../lib/interaction";
|
|
12
12
|
import { logDebug, logError } from "../lib/log";
|
|
13
|
+
import { parkPromptContext } from "../lib/parked-context";
|
|
13
14
|
import { runRetrieval } from "../lib/retrieval";
|
|
14
15
|
import { ensureIndex } from "../lib/retrieval-index";
|
|
15
16
|
import { isEnabled } from "../lib/settings";
|
|
@@ -60,25 +61,12 @@ export async function getRetrievalReminder(prompt: string): Promise<string | nul
|
|
|
60
61
|
return result.reminder;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
/**
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if (
|
|
69
|
-
process.stdout.write(JSON.stringify({ additional_context: reminder }));
|
|
70
|
-
} else if (isCodex()) {
|
|
71
|
-
process.stdout.write(
|
|
72
|
-
JSON.stringify({
|
|
73
|
-
hookSpecificOutput: {
|
|
74
|
-
hookEventName: "UserPromptSubmit",
|
|
75
|
-
additionalContext: reminder,
|
|
76
|
-
},
|
|
77
|
-
})
|
|
78
|
-
);
|
|
79
|
-
} else {
|
|
80
|
-
process.stdout.write(`${reminder}\n`);
|
|
81
|
-
}
|
|
64
|
+
/** MUST be called at most once per hook run — the JSON shapes are a single object
|
|
65
|
+
* on stdout, so all prompt-time context is merged before this call. */
|
|
66
|
+
function writeForAgent(reminder: string, sessionId?: string): void {
|
|
67
|
+
const response = promptContextResponse(reminder);
|
|
68
|
+
if (response) process.stdout.write(response);
|
|
69
|
+
else if (sessionId) parkPromptContext(sessionId, reminder);
|
|
82
70
|
}
|
|
83
71
|
|
|
84
72
|
/** Merge every prompt-time source — the wall clock, contextual steering, skill
|
|
@@ -108,6 +96,6 @@ export async function injectPromptContext(
|
|
|
108
96
|
sessionId?: string
|
|
109
97
|
): Promise<string | null> {
|
|
110
98
|
const combined = await getPromptContext(prompt, sessionId);
|
|
111
|
-
if (combined) writeForAgent(combined);
|
|
99
|
+
if (combined) writeForAgent(combined, sessionId);
|
|
112
100
|
return combined;
|
|
113
101
|
}
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -245,6 +245,28 @@ function claudeBlock(reason: string, hookEventName?: string): string {
|
|
|
245
245
|
});
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
+
/** Cursor's beforeSubmitPrompt documents no context field. */
|
|
249
|
+
export const hearsPromptContext = () => !isCursor();
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* How a prompt hook hands context to the model, in the shape this agent reads.
|
|
253
|
+
* Null for the Copilot CLI, which drops a userPromptSubmitted hook's output and
|
|
254
|
+
* takes the context on userPromptTransformed instead.
|
|
255
|
+
*/
|
|
256
|
+
export function promptContextResponse(context: string): string | null {
|
|
257
|
+
if (isCopilot()) return null;
|
|
258
|
+
if (isCursor()) return JSON.stringify({ additional_context: context });
|
|
259
|
+
if (isCodex() || isVscode()) {
|
|
260
|
+
return JSON.stringify({
|
|
261
|
+
hookSpecificOutput: {
|
|
262
|
+
hookEventName: "UserPromptSubmit",
|
|
263
|
+
additionalContext: context,
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return `${context}\n`;
|
|
268
|
+
}
|
|
269
|
+
|
|
248
270
|
export function blockResponse(reason: string, hookEventName?: string): string {
|
|
249
271
|
if (isCursor()) {
|
|
250
272
|
return JSON.stringify({ permission: "deny", user_message: reason });
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The fields each agent's prompt and stop hooks use for the same things.
|
|
3
|
+
* Claude Code, Codex and VS Code send snake_case, the Copilot CLI camelCase, and
|
|
4
|
+
* Cursor names the session `conversation_id` and hands its final reply to
|
|
5
|
+
* `afterAgentResponse` as `text`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { recordReply } from "./interaction";
|
|
9
|
+
|
|
10
|
+
export interface HookTurnPayload {
|
|
11
|
+
session_id?: string;
|
|
12
|
+
sessionId?: string;
|
|
13
|
+
conversation_id?: string;
|
|
14
|
+
last_assistant_message?: string | null;
|
|
15
|
+
lastAssistantMessage?: string | null;
|
|
16
|
+
text?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hookSessionId(payload: HookTurnPayload | null | undefined) {
|
|
20
|
+
return payload?.session_id ?? payload?.sessionId ?? payload?.conversation_id;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function hookFinalReply(payload: HookTurnPayload | null | undefined) {
|
|
24
|
+
return (
|
|
25
|
+
payload?.last_assistant_message ??
|
|
26
|
+
payload?.lastAssistantMessage ??
|
|
27
|
+
payload?.text ??
|
|
28
|
+
undefined
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fileFinalReply(payload: HookTurnPayload | null | undefined): void {
|
|
33
|
+
const sessionId = hookSessionId(payload);
|
|
34
|
+
const reply = hookFinalReply(payload);
|
|
35
|
+
if (sessionId && reply) recordReply(sessionId, reply);
|
|
36
|
+
}
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import { resolve } from "node:path";
|
|
14
14
|
import { currentAttribution } from "./actor";
|
|
15
|
+
import { hearsPromptContext } from "./agent";
|
|
15
16
|
import {
|
|
16
17
|
type Baseline,
|
|
17
18
|
DEFAULT_BASELINE,
|
|
@@ -50,6 +51,7 @@ export interface TurnEvent extends MoodTurn {
|
|
|
50
51
|
reaction: Reaction | null;
|
|
51
52
|
mood?: string;
|
|
52
53
|
hinted?: boolean;
|
|
54
|
+
runtime?: string;
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
interface SessionTrack {
|
|
@@ -252,7 +254,11 @@ export function observeTurn(
|
|
|
252
254
|
const recent = [...track.recent, event].slice(-RECENT_KEPT);
|
|
253
255
|
const mood = readMood(recent, baseline(session, now));
|
|
254
256
|
const reminder = moodReminder(mood, track.mood ?? "");
|
|
255
|
-
appendEvent({
|
|
257
|
+
appendEvent({
|
|
258
|
+
...event,
|
|
259
|
+
mood: mood.key,
|
|
260
|
+
hinted: reminder !== null && hearsPromptContext(),
|
|
261
|
+
});
|
|
256
262
|
sampleReaction(event, text, track);
|
|
257
263
|
writeTrack(session, {
|
|
258
264
|
...track,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Copilot CLI drops what its userPromptSubmitted hook returns, but lets the
|
|
3
|
+
* userPromptTransformed hook that follows rewrite the prompt the model receives.
|
|
4
|
+
* The prompt hook parks its context here; the transform hook appends it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { type HookTurnPayload, hookSessionId } from "./hook-turn";
|
|
10
|
+
import { logError } from "./log";
|
|
11
|
+
import { ensureDir, paths } from "./paths";
|
|
12
|
+
|
|
13
|
+
const MAX_AGE_MS = 2 * 60_000;
|
|
14
|
+
|
|
15
|
+
interface Parked {
|
|
16
|
+
context: string;
|
|
17
|
+
at: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface TransformedPromptPayload extends HookTurnPayload {
|
|
21
|
+
transformedPrompt?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function parkedPath(): string {
|
|
25
|
+
return resolve(ensureDir(paths.state()), "parked-prompt-context.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function readParked(): Record<string, Parked> {
|
|
29
|
+
const path = parkedPath();
|
|
30
|
+
if (!existsSync(path)) return {};
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isFresh(parked: Parked, now: Date): boolean {
|
|
39
|
+
return now.getTime() - new Date(parked.at).getTime() <= MAX_AGE_MS;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeFresh(parked: Record<string, Parked>, now: Date): void {
|
|
43
|
+
const fresh = Object.entries(parked).filter(([, p]) => isFresh(p, now));
|
|
44
|
+
writeFileSync(parkedPath(), JSON.stringify(Object.fromEntries(fresh)), "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function parkPromptContext(session: string, context: string, now = new Date()) {
|
|
48
|
+
try {
|
|
49
|
+
writeFresh({ ...readParked(), [session]: { context, at: now.toISOString() } }, now);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
logError("parkPromptContext", err);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function takePromptContext(session: string, now: Date): string | null {
|
|
56
|
+
const parked = readParked();
|
|
57
|
+
const entry = parked[session];
|
|
58
|
+
if (!entry) return null;
|
|
59
|
+
delete parked[session];
|
|
60
|
+
writeFresh(parked, now);
|
|
61
|
+
return isFresh(entry, now) ? entry.context : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The userPromptTransformed reply that appends the parked context, or null to leave the prompt alone. */
|
|
65
|
+
export function transformedPromptResponse(
|
|
66
|
+
payload: TransformedPromptPayload | null,
|
|
67
|
+
now = new Date()
|
|
68
|
+
): string | null {
|
|
69
|
+
const session = hookSessionId(payload);
|
|
70
|
+
const facing = payload?.transformedPrompt;
|
|
71
|
+
if (!session || !facing) return null;
|
|
72
|
+
const context = takePromptContext(session, now);
|
|
73
|
+
if (!context) return null;
|
|
74
|
+
return JSON.stringify({ modifiedTransformedPrompt: `${facing}\n\n${context}` });
|
|
75
|
+
}
|
package/src/hooks/lib/stop.ts
CHANGED
|
@@ -20,6 +20,12 @@ import { resetTab } from "../handlers/tab";
|
|
|
20
20
|
import { updateCounts } from "../handlers/update-counts";
|
|
21
21
|
import { captureWorkSession } from "../handlers/work-session";
|
|
22
22
|
import { spawnDetachedInference } from "./detached-inference";
|
|
23
|
+
import {
|
|
24
|
+
fileFinalReply,
|
|
25
|
+
type HookTurnPayload,
|
|
26
|
+
hookFinalReply,
|
|
27
|
+
hookSessionId,
|
|
28
|
+
} from "./hook-turn";
|
|
23
29
|
import { recordReply } from "./interaction";
|
|
24
30
|
import { logDebug, logError } from "./log";
|
|
25
31
|
import { assets, ensureDir, paths } from "./paths";
|
|
@@ -27,9 +33,30 @@ import {
|
|
|
27
33
|
extractContent,
|
|
28
34
|
extractLastAssistant,
|
|
29
35
|
parseMessages,
|
|
36
|
+
readTranscriptFile,
|
|
30
37
|
withFinalReply,
|
|
31
38
|
} from "./transcript";
|
|
32
39
|
|
|
40
|
+
export interface StopTurnPayload extends HookTurnPayload {
|
|
41
|
+
transcript_path?: string | null;
|
|
42
|
+
transcriptPath?: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Everything a stop hook does once the agent's payload is read. */
|
|
46
|
+
export async function stopTurn(payload: StopTurnPayload | null): Promise<void> {
|
|
47
|
+
const transcriptPath = payload?.transcript_path ?? payload?.transcriptPath;
|
|
48
|
+
const messages = transcriptPath ? readTranscriptFile(transcriptPath) : [];
|
|
49
|
+
if (messages.length >= 2) {
|
|
50
|
+
await runStopHandlers(JSON.stringify(messages), {
|
|
51
|
+
lastAssistantMessage: hookFinalReply(payload),
|
|
52
|
+
sessionId: hookSessionId(payload),
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
logDebug("stopTurn", "No readable transcript; filing only the final reply");
|
|
57
|
+
fileFinalReply(payload);
|
|
58
|
+
}
|
|
59
|
+
|
|
33
60
|
interface RunStopHandlersOptions {
|
|
34
61
|
lastAssistantMessage?: string;
|
|
35
62
|
sessionId?: string;
|
|
@@ -15,7 +15,8 @@ const HELP = `
|
|
|
15
15
|
PAL Interaction report
|
|
16
16
|
|
|
17
17
|
Reads the measured turns in memory/signals/interaction/ (features only, no
|
|
18
|
-
text) and prints turns, channels, reactions, the hints sent,
|
|
18
|
+
text) and prints turns, channels, reactions, the hints sent, per agent the
|
|
19
|
+
turns, replies filed and hints sent, and for each
|
|
19
20
|
label (short, long, fast, skimming, friction) how the replies written while
|
|
20
21
|
it was active compare with the replies written while none was.
|
|
21
22
|
|
|
@@ -22,10 +22,17 @@ interface ReplyStats {
|
|
|
22
22
|
correctedShare: number;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
interface AgentCounts {
|
|
26
|
+
turns: number;
|
|
27
|
+
replies: number;
|
|
28
|
+
hints: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export interface InteractionSummary {
|
|
26
32
|
turns: number;
|
|
27
33
|
sessions: number;
|
|
28
34
|
channels: Record<string, number>;
|
|
35
|
+
agents: Record<string, AgentCounts>;
|
|
29
36
|
reactions: Record<string, number>;
|
|
30
37
|
hints: Record<string, number>;
|
|
31
38
|
unlogged: number;
|
|
@@ -70,6 +77,20 @@ function repliesUnderMood(events: TurnEvent[]): ReplyUnderMood[] {
|
|
|
70
77
|
return replies;
|
|
71
78
|
}
|
|
72
79
|
|
|
80
|
+
/** A turn carries the reply that answered the turn before it, so a filed reply shows up there. */
|
|
81
|
+
function byAgent(events: TurnEvent[]): Record<string, AgentCounts> {
|
|
82
|
+
const agents: Record<string, AgentCounts> = {};
|
|
83
|
+
for (const e of events) {
|
|
84
|
+
const agent = e.runtime ?? "unknown";
|
|
85
|
+
const counts = agents[agent] ?? { turns: 0, replies: 0, hints: 0 };
|
|
86
|
+
agents[agent] = counts;
|
|
87
|
+
counts.turns++;
|
|
88
|
+
if (e.reply) counts.replies++;
|
|
89
|
+
if (e.hinted) counts.hints++;
|
|
90
|
+
}
|
|
91
|
+
return agents;
|
|
92
|
+
}
|
|
93
|
+
|
|
73
94
|
export function summarize(events: TurnEvent[]): InteractionSummary {
|
|
74
95
|
const replies = repliesUnderMood(events);
|
|
75
96
|
const hinted = events.filter((e) => e.hinted);
|
|
@@ -77,6 +98,7 @@ export function summarize(events: TurnEvent[]): InteractionSummary {
|
|
|
77
98
|
turns: events.length,
|
|
78
99
|
sessions: new Set(events.map((e) => e.session)).size,
|
|
79
100
|
channels: tally(events.map((e) => e.channel ?? "unknown")),
|
|
101
|
+
agents: byAgent(events),
|
|
80
102
|
reactions: tally(events.flatMap((e) => (e.reaction ? [e.reaction] : []))),
|
|
81
103
|
hints: tally(hinted.map((e) => e.mood || "back to usual")),
|
|
82
104
|
unlogged: events.filter((e) => e.mood === undefined).length,
|
|
@@ -119,8 +141,20 @@ function labelLine(label: string, s: ReplyStats, usual: ReplyStats): string {
|
|
|
119
141
|
].join(" · ");
|
|
120
142
|
}
|
|
121
143
|
|
|
122
|
-
function plural(n: number, noun: string): string {
|
|
123
|
-
return `${n} ${
|
|
144
|
+
function plural(n: number, noun: string, nouns = `${noun}s`): string {
|
|
145
|
+
return `${n} ${n === 1 ? noun : nouns}`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function agentLines(agents: Record<string, AgentCounts>): string[] {
|
|
149
|
+
return Object.entries(agents)
|
|
150
|
+
.sort(([, a], [, b]) => b.turns - a.turns)
|
|
151
|
+
.map(([agent, c]) =>
|
|
152
|
+
[
|
|
153
|
+
` ${agent.padEnd(9)} ${plural(c.turns, "turn")}`,
|
|
154
|
+
`${plural(c.replies, "reply", "replies")} filed`,
|
|
155
|
+
`${plural(c.hints, "hint")} sent`,
|
|
156
|
+
].join(" · ")
|
|
157
|
+
);
|
|
124
158
|
}
|
|
125
159
|
|
|
126
160
|
export function reportLines(summary: InteractionSummary, days: number): string[] {
|
|
@@ -132,6 +166,9 @@ export function reportLines(summary: InteractionSummary, days: number): string[]
|
|
|
132
166
|
`Reactions: ${shares(summary.reactions)}`,
|
|
133
167
|
`Hints sent: ${counts(summary.hints)}`,
|
|
134
168
|
"",
|
|
169
|
+
"By agent:",
|
|
170
|
+
...agentLines(summary.agents),
|
|
171
|
+
"",
|
|
135
172
|
"Replies written while a label was active, against replies while none was:",
|
|
136
173
|
...LABELS.map((label) => labelLine(label, summary.byLabel[label], summary.usual)),
|
|
137
174
|
];
|