portable-agent-layer 0.40.0 → 0.41.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Failure-principle handler — extracts an actionable principle from a low-rated
3
+ * session via inference, then persists the failure record.
4
+ *
5
+ * Detached from the Stop hook (lib/stop.ts) because claude --print cold-start
6
+ * can exceed the Stop hook's reasonable budget. Parent writes pending data +
7
+ * transcript to tmp files, spawns this script with both paths, and returns
8
+ * immediately. Child reads, runs inference, calls captureFailure, and unlinks
9
+ * the tmp files.
10
+ */
11
+
12
+ import { existsSync } from "node:fs";
13
+ import { readFile, unlink } from "node:fs/promises";
14
+ import { extractContent, parseMessages } from "../lib/transcript";
15
+ import { captureFailure } from "./failure";
16
+
17
+ interface PendingFailure {
18
+ rating: number;
19
+ context: string;
20
+ detailedContext?: string;
21
+ principle?: string;
22
+ responsePreview?: string;
23
+ userPreview?: string;
24
+ cwd?: string;
25
+ }
26
+
27
+ /**
28
+ * Inference the principle (if missing) and persist the failure record.
29
+ * Reads pending data + transcript from the provided tmp paths and unlinks them.
30
+ */
31
+ async function processFailurePrinciple(
32
+ pendingPath: string,
33
+ transcriptPath: string
34
+ ): Promise<void> {
35
+ const { logDebug, logError } = await import("../lib/log");
36
+ try {
37
+ if (!existsSync(pendingPath) || !existsSync(transcriptPath)) {
38
+ logError(
39
+ "failure-principle",
40
+ `missing input: pending=${existsSync(pendingPath)} transcript=${existsSync(transcriptPath)}`
41
+ );
42
+ return;
43
+ }
44
+
45
+ const pending = JSON.parse(await readFile(pendingPath, "utf-8")) as PendingFailure;
46
+ const transcript = await readFile(transcriptPath, "utf-8");
47
+ logDebug("failure-principle", `processing rating=${pending.rating}`);
48
+
49
+ let { principle, detailedContext } = pending;
50
+ if (!principle) {
51
+ try {
52
+ const { inference } = await import("../lib/inference");
53
+ const msgs = parseMessages(transcript);
54
+ const recent = msgs
55
+ .slice(-10)
56
+ .map((m) => `${m.role.toUpperCase()}: ${extractContent(m).slice(0, 300)}`)
57
+ .join("\n\n");
58
+
59
+ const result = await inference({
60
+ system: `Analyze this failed AI interaction. The user rated it ${pending.rating}/10.
61
+
62
+ Return JSON:
63
+ {
64
+ "principle": "<one actionable rule the AI should follow, 10-20 words. Start with a verb: 'Verify...', 'Always...', 'Never...', 'Ask before...'>",
65
+ "detailed_context": "<what went wrong and why, 50-150 words>"
66
+ }`,
67
+ user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
68
+ maxTokens: 400,
69
+ timeout: 60000,
70
+ jsonSchema: {
71
+ type: "object" as const,
72
+ properties: {
73
+ principle: { type: "string" as const },
74
+ detailed_context: { type: "string" as const },
75
+ },
76
+ required: ["principle", "detailed_context"],
77
+ additionalProperties: false,
78
+ },
79
+ caller: "failure-principle",
80
+ });
81
+
82
+ if (result.success && result.output) {
83
+ const parsed = JSON.parse(result.output) as {
84
+ principle?: string;
85
+ detailed_context?: string;
86
+ };
87
+ principle = parsed.principle || undefined;
88
+ detailedContext ??= parsed.detailed_context || undefined;
89
+ } else {
90
+ logError("failure-principle", `inference failed (no output)`);
91
+ }
92
+ } catch (err) {
93
+ logError("failure-principle:inference", err);
94
+ }
95
+ }
96
+
97
+ await captureFailure(
98
+ pending.rating,
99
+ pending.context,
100
+ transcript,
101
+ detailedContext,
102
+ principle,
103
+ pending.cwd
104
+ );
105
+ logDebug("failure-principle", "captureFailure done");
106
+ } catch (err) {
107
+ logError("failure-principle", err);
108
+ } finally {
109
+ await unlink(pendingPath).catch(() => {});
110
+ await unlink(transcriptPath).catch(() => {});
111
+ }
112
+ }
113
+
114
+ // Detached child entry point
115
+ if (process.argv[2] === "--run") {
116
+ const pendingPath = process.argv[3];
117
+ const transcriptPath = process.argv[4];
118
+ if (pendingPath && transcriptPath) {
119
+ await processFailurePrinciple(pendingPath, transcriptPath);
120
+ }
121
+ process.exit(0);
122
+ }
@@ -10,7 +10,8 @@
10
10
 
11
11
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
12
  import { resolve } from "node:path";
13
- import { inference } from "../lib/inference";
13
+ import { spawnDetachedInference } from "../lib/detached-inference";
14
+ import { canInfer, inference } from "../lib/inference";
14
15
  import { paths } from "../lib/paths";
15
16
  import { emitRating } from "../lib/signals";
16
17
  import { now } from "../lib/time";
@@ -284,13 +285,10 @@ function handleRating(
284
285
 
285
286
  // ── Implicit Sentiment ──
286
287
 
287
- async function handleImplicitSentiment(
288
- message: string,
289
- sessionId?: string
290
- ): Promise<void> {
288
+ function handleImplicitSentiment(message: string, sessionId?: string): void {
291
289
  const trimmed = message.trim();
292
290
 
293
- // Fast-path: short praise -> rating 8
291
+ // Fast-path: short praise -> rating 8 (synchronous, no inference)
294
292
  if (isPraise(trimmed)) {
295
293
  handleRating(
296
294
  8,
@@ -311,27 +309,48 @@ async function handleImplicitSentiment(
311
309
  if (trimmed.length < 5 || trimmed.length > 500) return;
312
310
  if (/^[/$`{]/.test(trimmed) || trimmed.includes("\n\n")) return;
313
311
 
314
- const lastResponse = getLastResponse(sessionId).slice(0, 300);
315
- const contextBlock = lastResponse
316
- ? `CONTEXT (last AI response excerpt):\n${lastResponse}\n\nCURRENT USER MESSAGE:\n${trimmed.slice(0, 300)}`
317
- : trimmed.slice(0, 300);
318
-
319
- const result = await inference({
320
- system: SENTIMENT_SYSTEM_PROMPT,
321
- user: contextBlock,
322
- maxTokens: 500,
323
- timeout: 8000,
324
- jsonSchema: SENTIMENT_SCHEMA,
325
- });
312
+ // Inference path — detach to background. claude --print has 3-5s of cold-start
313
+ // overhead per call; running inline would block UserPromptSubmit and exceed
314
+ // any reasonable in-line budget. Uses the shared detach helper.
315
+ if (!canInfer()) return;
316
+ const msgB64 = Buffer.from(trimmed.slice(0, 800)).toString("base64");
317
+ spawnDetachedInference(
318
+ import.meta.filename,
319
+ ["--sentiment", sessionId ?? "", msgB64],
320
+ "rating"
321
+ );
322
+ }
326
323
 
327
- if (result.usage) logTokenUsage("rating", result.usage);
324
+ /**
325
+ * Background sentiment mode: called via --sentiment flag from a detached subprocess.
326
+ * Runs the heavy inference, parses the result, and writes the rating if confident.
327
+ */
328
+ async function runSentimentInferenceAndStore(
329
+ message: string,
330
+ sessionId?: string
331
+ ): Promise<void> {
332
+ try {
333
+ const trimmed = message.trim();
334
+ const lastResponse = getLastResponse(sessionId).slice(0, 300);
335
+ const contextBlock = lastResponse
336
+ ? `CONTEXT (last AI response excerpt):\n${lastResponse}\n\nCURRENT USER MESSAGE:\n${trimmed.slice(0, 300)}`
337
+ : trimmed.slice(0, 300);
338
+
339
+ const result = await inference({
340
+ system: SENTIMENT_SYSTEM_PROMPT,
341
+ user: contextBlock,
342
+ maxTokens: 500,
343
+ timeout: 60000,
344
+ jsonSchema: SENTIMENT_SCHEMA,
345
+ caller: "rating",
346
+ sessionId,
347
+ });
328
348
 
329
- if (!result.success || !result.output) return;
349
+ if (result.usage) logTokenUsage("rating", result.usage);
350
+ if (!result.success || !result.output) return;
330
351
 
331
- try {
332
352
  const parsed = JSON.parse(result.output) as SentimentResult;
333
353
 
334
- // Skip if no sentiment detected or low confidence
335
354
  if (parsed.rating === null) return;
336
355
  if (parsed.confidence < MIN_CONFIDENCE) return;
337
356
 
@@ -349,13 +368,13 @@ async function handleImplicitSentiment(
349
368
  }
350
369
  } catch (err) {
351
370
  const { logError } = await import("../lib/log");
352
- logError("rating:implicit", err);
371
+ logError("rating:sentiment-child", err);
353
372
  }
354
373
  }
355
374
 
356
375
  // ── Main Export ──
357
376
 
358
- export async function captureRating(message: string, sessionId?: string): Promise<void> {
377
+ export function captureRating(message: string, sessionId?: string): void {
359
378
  // Strip IDE/system-injected tags to recover raw user text
360
379
  const cleaned = stripInjectedTags(message);
361
380
 
@@ -374,6 +393,18 @@ export async function captureRating(message: string, sessionId?: string): Promis
374
393
  return;
375
394
  }
376
395
 
377
- // Path 2: Implicit sentiment (requires PAL_ANTHROPIC_API_KEY inference silently no-ops without it)
378
- await handleImplicitSentiment(cleaned, sessionId);
396
+ // Path 2: Implicit sentiment — fast-paths run synchronously, the inference
397
+ // path detaches to a background bun subprocess (mirrors session-name).
398
+ handleImplicitSentiment(cleaned, sessionId);
399
+ }
400
+
401
+ // Background sentiment entry point
402
+ if (process.argv[2] === "--sentiment") {
403
+ const sid = process.argv[3];
404
+ const msgB64 = process.argv[4];
405
+ if (msgB64) {
406
+ const msg = Buffer.from(msgB64, "base64").toString("utf-8");
407
+ await runSentimentInferenceAndStore(msg, sid === "" ? undefined : sid);
408
+ }
409
+ process.exit(0);
379
410
  }
@@ -13,7 +13,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
13
  import { unlink, writeFile } from "node:fs/promises";
14
14
  import { resolve } from "node:path";
15
15
  import { stringify } from "../lib/frontmatter";
16
- import { hasApiKey, inference } from "../lib/inference";
16
+ import { canInfer, inference } from "../lib/inference";
17
17
  import { categorizeLearning } from "../lib/learning-category";
18
18
  import { logDebug, logError } from "../lib/log";
19
19
  import { ensureDir, paths } from "../lib/paths";
@@ -124,7 +124,7 @@ interface IntelligenceOutput {
124
124
 
125
125
  // ── Main handler ──
126
126
 
127
- export async function captureSessionIntelligence(
127
+ async function captureSessionIntelligence(
128
128
  transcript: string,
129
129
  sessionId?: string
130
130
  ): Promise<void> {
@@ -137,9 +137,9 @@ export async function captureSessionIntelligence(
137
137
  if (prev && messages.length - prev.messageCount < MIN_NEW_MESSAGES) return;
138
138
  }
139
139
 
140
- // Skip if no API key
141
- if (!hasApiKey()) {
142
- logDebug("session-intelligence", "Skipped: no PAL_ANTHROPIC_API_KEY");
140
+ // Skip if no inference path is available (no CLI binary AND no API key)
141
+ if (!canInfer()) {
142
+ logDebug("session-intelligence", "Skipped: canInfer() false (no CLI + no API key)");
143
143
  return;
144
144
  }
145
145
 
@@ -178,8 +178,10 @@ export async function captureSessionIntelligence(
178
178
  ].join("\n"),
179
179
  user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
180
180
  maxTokens: 350,
181
- timeout: 15000,
181
+ timeout: 60000,
182
182
  jsonSchema: INTELLIGENCE_SCHEMA,
183
+ caller: "session-intelligence",
184
+ sessionId,
183
185
  });
184
186
 
185
187
  if (result.usage) logTokenUsage("session-intelligence", result.usage);
@@ -246,3 +248,21 @@ export async function captureSessionIntelligence(
246
248
  if (sessionId) markCaptured(sessionId, filepath, messages.length);
247
249
  logDebug("session-intelligence", `Learning captured: ${title}`);
248
250
  }
251
+
252
+ // Detached child entry point — re-reads transcript from tmp path, then unlinks it.
253
+ if (process.argv[2] === "--run") {
254
+ const sid = process.argv[3];
255
+ const transcriptPath = process.argv[4];
256
+ if (transcriptPath) {
257
+ const { readFile, unlink } = await import("node:fs/promises");
258
+ try {
259
+ const transcript = await readFile(transcriptPath, "utf-8");
260
+ await captureSessionIntelligence(transcript, sid === "" ? undefined : sid);
261
+ } catch (err) {
262
+ logError("session-intelligence:run", err);
263
+ } finally {
264
+ await unlink(transcriptPath).catch(() => {});
265
+ }
266
+ }
267
+ process.exit(0);
268
+ }
@@ -9,8 +9,8 @@
9
9
  * This avoids the 1-5s inference latency that previously blocked every first prompt.
10
10
  */
11
11
 
12
- import { spawn } from "node:child_process";
13
- import { hasApiKey, inference } from "../lib/inference";
12
+ import { spawnDetachedInference } from "../lib/detached-inference";
13
+ import { canInfer, inference } from "../lib/inference";
14
14
  import { logDebug, logError } from "../lib/log";
15
15
  import {
16
16
  extractFallbackName,
@@ -41,24 +41,14 @@ export async function captureSessionName(
41
41
  writeSessionName(sessionId, name);
42
42
  logDebug("session-name", `Named from prompt: "${name}"`);
43
43
 
44
- // Spawn detached background process to upgrade with Haiku inference
45
- if (!hasApiKey()) return;
46
- try {
47
- const promptB64 = Buffer.from(message.slice(0, 800)).toString("base64");
48
- const child = spawn(
49
- "bun",
50
- [import.meta.filename, "--upgrade", sessionId, promptB64, name],
51
- {
52
- detached: true,
53
- stdio: "ignore",
54
- env: { ...process.env, CLAUDECODE: undefined },
55
- }
56
- );
57
- child.unref();
58
- logDebug("session-name", "Spawned background Haiku upgrade");
59
- } catch {
60
- // Non-critical — deterministic name is already stored
61
- }
44
+ // Spawn detached background process to upgrade via inference
45
+ if (!canInfer()) return;
46
+ const promptB64 = Buffer.from(message.slice(0, 800)).toString("base64");
47
+ spawnDetachedInference(
48
+ import.meta.filename,
49
+ ["--upgrade", sessionId, promptB64, name],
50
+ "session-name"
51
+ );
62
52
  }
63
53
 
64
54
  /**
@@ -80,7 +70,9 @@ async function upgradeWithInference(
80
70
  system: NAME_PROMPT,
81
71
  user: `Generate a 4-word title for: "${promptText}"`,
82
72
  maxTokens: 20,
83
- timeout: 10000,
73
+ timeout: 60000,
74
+ caller: "session-name",
75
+ sessionId,
84
76
  });
85
77
 
86
78
  if (result.usage) logTokenUsage("session-name", result.usage);
@@ -1,27 +1,42 @@
1
1
  /**
2
2
  * Agent detection and output format adapters.
3
3
  *
4
- * Cursor, Codex, and Claude Code use different JSON contracts for hook I/O.
5
- * These helpers normalize the differences so hook handlers stay clean.
4
+ * Each supported agent (Claude Code, Cursor, Codex, Copilot, opencode) uses a
5
+ * different JSON contract for hook I/O and a different mechanism for spawning
6
+ * one-shot subscription-backed inference. These helpers identify which agent
7
+ * is currently running PAL so downstream code can dispatch accordingly.
8
+ *
9
+ * Primary signal: PAL_AGENT env var, set by every install template/plugin in
10
+ * `assets/templates/*` and `src/targets/opencode/plugin.ts`. IDE-provided env
11
+ * vars are used as secondary fallbacks for environments that forward them.
6
12
  */
7
13
 
8
- type AgentType = "claude" | "cursor" | "codex";
14
+ type AgentType = "claude" | "cursor" | "codex" | "copilot" | "opencode";
15
+
16
+ const KNOWN_AGENTS: ReadonlySet<AgentType> = new Set([
17
+ "claude",
18
+ "cursor",
19
+ "codex",
20
+ "copilot",
21
+ "opencode",
22
+ ]);
9
23
 
10
- /** Detect which agent is running via environment variables */
11
- function detectAgent(): AgentType {
12
- // PAL_AGENT is set explicitly in hook command prefixes — most reliable signal.
13
- // IDE env vars (CURSOR_VERSION, CODEX_CLI_VERSION) are NOT reliably forwarded to
14
- // hook subprocesses, so PAL_AGENT is the primary detection mechanism.
15
- if (process.env.PAL_AGENT === "cursor") return "cursor";
16
- if (process.env.PAL_AGENT === "codex") return "codex";
17
- // Fallbacks for environments that do forward IDE env vars
24
+ /** Detect which agent is currently running PAL. Defaults to "claude". */
25
+ export function getActiveAgent(): AgentType {
26
+ const explicit = process.env.PAL_AGENT;
27
+ if (explicit && KNOWN_AGENTS.has(explicit as AgentType)) {
28
+ return explicit as AgentType;
29
+ }
18
30
  if (process.env.CURSOR_VERSION) return "cursor";
19
31
  if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
20
32
  return "claude";
21
33
  }
22
34
 
23
- export const isCursor = () => detectAgent() === "cursor";
24
- export const isCodex = () => detectAgent() === "codex";
35
+ export const isClaude = () => getActiveAgent() === "claude";
36
+ export const isCursor = () => getActiveAgent() === "cursor";
37
+ export const isCodex = () => getActiveAgent() === "codex";
38
+ export const isCopilot = () => getActiveAgent() === "copilot";
39
+ export const isOpencode = () => getActiveAgent() === "opencode";
25
40
 
26
41
  /**
27
42
  * Format a "block this action" response for the current agent.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Shared spawn helper for detached background inference calls.
3
+ *
4
+ * PAL hooks that need to run an inference but cannot block the parent hook
5
+ * process (UserPromptSubmit, Stop) spawn a detached bun subprocess that
6
+ * re-enters the same handler script with a mode flag. This helper centralizes
7
+ * the spawn boilerplate: detach + unref + CLAUDECODE-scrub + debug/error logs.
8
+ *
9
+ * Usage:
10
+ * spawnDetachedInference(
11
+ * import.meta.filename, // re-invokes this script
12
+ * ["--sentiment", sessionId, msgB64], // mode flag + payload args
13
+ * "rating" // scope for logs
14
+ * );
15
+ *
16
+ * Payloads over a few KB should be passed via file path (write to tmp,
17
+ * pass path) rather than argv to avoid ARG_MAX limits (~256KB on macOS).
18
+ */
19
+
20
+ import { spawn } from "node:child_process";
21
+ import { logDebug, logError } from "./log";
22
+
23
+ export function spawnDetachedInference(
24
+ scriptPath: string,
25
+ args: string[],
26
+ scope: string
27
+ ): void {
28
+ try {
29
+ const child = spawn("bun", [scriptPath, ...args], {
30
+ detached: true,
31
+ stdio: "ignore",
32
+ env: { ...process.env, CLAUDECODE: undefined },
33
+ });
34
+ child.unref();
35
+ logDebug(scope, `detached inference spawned: ${args[0] ?? "no-mode"}`);
36
+ } catch (err) {
37
+ logError(scope, err);
38
+ }
39
+ }
@@ -259,6 +259,7 @@ async function generateRecommendations(
259
259
  },
260
260
  required: ["recommendations"],
261
261
  },
262
+ caller: "graduation",
262
263
  });
263
264
 
264
265
  if (result.success && result.output) {