portable-agent-layer 0.22.0 → 0.23.1

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.
Files changed (42) hide show
  1. package/assets/agents/gemini-researcher.md +17 -3
  2. package/assets/agents/grok-researcher.md +19 -5
  3. package/assets/agents/multi-perspective-researcher.md +16 -2
  4. package/assets/agents/perplexity-researcher.md +17 -3
  5. package/assets/skills/analyze-pdf/SKILL.md +1 -1
  6. package/assets/skills/analyze-youtube/SKILL.md +1 -1
  7. package/assets/skills/extract-entities/SKILL.md +1 -1
  8. package/assets/skills/fyzz-chat-api/SKILL.md +3 -3
  9. package/assets/skills/reflect/SKILL.md +2 -2
  10. package/assets/skills/telos/SKILL.md +6 -6
  11. package/assets/templates/AGENTS.md.template +2 -2
  12. package/assets/templates/PAL/ALGORITHM.md +93 -10
  13. package/assets/templates/PAL/CONTEXT_ROUTING.md +17 -17
  14. package/assets/templates/PAL/MEMORY_SYSTEM.md +5 -5
  15. package/assets/templates/PAL/README.md +12 -9
  16. package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +1 -1
  17. package/assets/templates/pal-settings.json +6 -3
  18. package/assets/templates/settings.claude.json +2 -2
  19. package/package.json +3 -1
  20. package/src/cli/index.ts +4 -11
  21. package/src/hooks/handlers/failure.ts +3 -1
  22. package/src/hooks/handlers/rating.ts +17 -2
  23. package/src/hooks/handlers/reflect-trigger.ts +4 -4
  24. package/src/hooks/handlers/relationship.ts +1 -1
  25. package/src/hooks/handlers/session-intelligence.ts +324 -0
  26. package/src/hooks/handlers/session-name.ts +2 -2
  27. package/src/hooks/handlers/synthesis.ts +36 -0
  28. package/src/hooks/handlers/update-check.ts +2 -2
  29. package/src/hooks/handlers/work-learning.ts +1 -1
  30. package/src/hooks/lib/context.ts +119 -2
  31. package/src/hooks/lib/paths.ts +4 -12
  32. package/src/hooks/lib/security.ts +39 -28
  33. package/src/hooks/lib/stop.ts +56 -7
  34. package/src/hooks/lib/token-usage.ts +1 -0
  35. package/src/targets/claude/install.ts +1 -1
  36. package/src/targets/cursor/install.ts +7 -1
  37. package/src/targets/cursor/uninstall.ts +7 -0
  38. package/src/targets/lib.ts +125 -115
  39. package/src/targets/opencode/install.ts +4 -4
  40. package/src/tools/agent/algorithm-reflect.ts +2 -0
  41. package/src/tools/agent/synthesize.ts +361 -0
  42. package/src/tools/agent/thread.ts +162 -0
@@ -231,10 +231,11 @@ export function loadFailurePatterns(): string {
231
231
 
232
232
  const lines = entries.map((e) => {
233
233
  const label = e.rating ? `[${e.rating}/10]` : "";
234
- return `- ${label} ${e.context}`.trim();
234
+ const text = e.principle || e.context;
235
+ return `- ${label} ${text}`.trim();
235
236
  });
236
237
 
237
- return ["## Recent Failure Patterns (Avoid)", ...lines].join("\n");
238
+ return ["## Lessons from Recent Failures Apply These Now", ...lines].join("\n");
238
239
  } catch {
239
240
  return "";
240
241
  }
@@ -333,6 +334,116 @@ export function loadRelationshipContext(): string {
333
334
  }
334
335
  }
335
336
 
337
+ /** Load session intelligence from compact synthesis state */
338
+ export function loadSessionIntelligence(): string {
339
+ try {
340
+ const p = resolve(paths.state(), "synthesis.json");
341
+ if (!existsSync(p)) return "";
342
+ const state = JSON.parse(readFileSync(p, "utf-8"));
343
+
344
+ const lines: string[] = ["## Session Intelligence"];
345
+
346
+ // Open Threads — project-specific first, then global
347
+ if (state.threads?.length > 0) {
348
+ const cwd = process.cwd();
349
+ const here = state.threads.filter((t: { cwd?: string }) => t.cwd === cwd);
350
+ const other = state.threads.filter((t: { cwd?: string }) => t.cwd !== cwd);
351
+
352
+ if (here.length > 0) {
353
+ lines.push("");
354
+ lines.push(`**Open threads — this project (${here.length}):**`);
355
+ for (const t of here) {
356
+ lines.push(`- ${t.title} (opened ${t.opened})`);
357
+ if (t.context) lines.push(` ${t.context}`);
358
+ }
359
+ lines.push("→ These are directly relevant to your current work.");
360
+ }
361
+ if (other.length > 0) {
362
+ lines.push("");
363
+ lines.push(`**Open threads — other projects (${other.length}):**`);
364
+ for (const t of other) {
365
+ lines.push(`- ${t.title} (opened ${t.opened})`);
366
+ }
367
+ }
368
+ }
369
+
370
+ // Rating Trend
371
+ if (state.ratings?.count > 0) {
372
+ const r = state.ratings;
373
+ lines.push("");
374
+ lines.push(
375
+ `**Rating trend:** ${r.avg}/10 avg (last 10: ${r.recentAvg}/10, ${r.trend}).${r.lowCount > 0 ? ` ${r.lowCount} low ratings.` : ""}`
376
+ );
377
+ if (r.trend === "declining") {
378
+ lines.push(
379
+ "→ Trend is declining. Be extra careful with assumptions. Confirm before acting."
380
+ );
381
+ } else if (r.trend === "improving") {
382
+ lines.push("→ Trend is improving. Maintain current approach.");
383
+ } else if (r.lowCount > 5) {
384
+ lines.push(
385
+ "→ Multiple low ratings. Slow down, verify before acting, ask when uncertain."
386
+ );
387
+ }
388
+ }
389
+
390
+ // Algorithm Performance
391
+ if (state.algorithm?.reflectionCount > 0) {
392
+ const a = state.algorithm;
393
+ lines.push("");
394
+ lines.push(
395
+ `**Algorithm:** ${a.reflectionCount} reflections, ${a.passRate}% criteria pass rate, ${a.avgSentiment}/10 sentiment.`
396
+ );
397
+ if (a.passRate < 80) {
398
+ lines.push(
399
+ "→ Criteria pass rate is low. Invest more time in OBSERVE and PLAN phases."
400
+ );
401
+ }
402
+ if (a.recentObservations?.length > 0) {
403
+ const cwd = process.cwd();
404
+ const relevant = a.recentObservations.filter(
405
+ (o: { cwd?: string }) => !o.cwd || o.cwd === cwd
406
+ );
407
+ if (relevant.length > 0) {
408
+ lines.push("Recent self-observations (this project):");
409
+ for (const o of relevant) {
410
+ lines.push(`- [${o.date}] ${o.task}: "${o.observation}"`);
411
+ }
412
+ }
413
+ }
414
+ }
415
+
416
+ return lines.length > 1 ? lines.join("\n") : "";
417
+ } catch {
418
+ return "";
419
+ }
420
+ }
421
+
422
+ /** Load handoff state for the current project */
423
+ export function loadHandoff(): string {
424
+ try {
425
+ const p = resolve(paths.state(), "last-handoff.json");
426
+ if (!existsSync(p)) return "";
427
+ const handoffs = JSON.parse(readFileSync(p, "utf-8"));
428
+ const cwd = process.cwd();
429
+ const entry = handoffs[cwd];
430
+ if (!entry?.handoff || entry.status !== "in-progress") return "";
431
+
432
+ const age = Date.now() - new Date(entry.timestamp).getTime();
433
+ if (age > 7 * 24 * 60 * 60 * 1000) return ""; // stale after 7 days
434
+
435
+ return [
436
+ "## Pick Up Where You Left Off",
437
+ `*Previous session: ${entry.title}*`,
438
+ "",
439
+ entry.handoff,
440
+ "→ Continue this work or explicitly close it before starting something new.",
441
+ ].join("\n");
442
+ } catch {
443
+ return "";
444
+ }
445
+ }
446
+
336
447
  /**
337
448
  * Build the <system-reminder> content for the AI.
338
449
  *
@@ -358,10 +469,16 @@ export function buildSystemReminder(): string {
358
469
  ? loadSynthesisRecommendations()
359
470
  : "";
360
471
  const opinions = isEnabled(settings, "opinions") ? loadOpinionContext() : "";
472
+ const intelligence = isEnabled(settings, "sessionIntelligence")
473
+ ? loadSessionIntelligence()
474
+ : "";
475
+ const handoff = isEnabled(settings, "handoff") ? loadHandoff() : "";
361
476
  const parts: string[] = [];
362
477
  if (startup) parts.push(startup);
478
+ if (handoff) parts.push(handoff);
363
479
  if (wisdom) parts.push(wisdom);
364
480
  if (opinions) parts.push(opinions);
481
+ if (intelligence) parts.push(intelligence);
365
482
  if (relationship) parts.push(relationship);
366
483
  if (projectHistory) parts.push(projectHistory);
367
484
  if (digest) parts.push(digest);
@@ -12,20 +12,12 @@ export function palPkg(): string {
12
12
  }
13
13
 
14
14
  /**
15
- * Root of the user's personal state (telos, memory, etc.).
16
- * In repo mode: same as palPkg() (the repo root).
17
- * In package mode: ~/.pal/ (or PAL_HOME override).
18
- *
19
- * Repo mode is detected by the presence of .palroot next to the package.
20
- * This file is not included in the npm package, so it only exists in cloned repos.
15
+ * Root of the user's personal state (telos, memory, docs, tools, skills).
16
+ * Always resolves to ~/.pal/ regardless of where the package lives.
17
+ * Power users who want memory/telos versioned in a repo can override via PAL_HOME.
21
18
  */
22
19
  export function palHome(): string {
23
- if (process.env.PAL_HOME) return process.env.PAL_HOME;
24
-
25
- const pkgRoot = palPkg();
26
- if (existsSync(resolve(pkgRoot, ".palroot"))) return pkgRoot;
27
-
28
- return resolve(homedir(), ".pal");
20
+ return process.env.PAL_HOME || resolve(homedir(), ".pal");
29
21
  }
30
22
 
31
23
  /** Ensure a directory exists, creating it recursively if needed */
@@ -47,8 +47,6 @@ export const HOOK_MANAGED_DIRS = [
47
47
  "memory/relationship",
48
48
  "memory/wisdom/state",
49
49
  "memory/projects",
50
- ".agents/PAL/memory",
51
- ".agents/PAL/telos",
52
50
  ];
53
51
 
54
52
  /** Escape a string for use in a RegExp */
@@ -63,8 +61,11 @@ export const PROTECTED_PATHS: RegExp[] = [
63
61
  /^\/System\//,
64
62
  /\.ssh\/(?!config)/,
65
63
  /\.gnupg\//,
66
- // Derived from HOOK_MANAGED_FILES
67
- ...HOOK_MANAGED_FILES.map((name) => new RegExp(`[/\\\\]${escapeRegExp(name)}$`)),
64
+ // Derived from HOOK_MANAGED_FILES — scoped to managed roots only
65
+ ...HOOK_MANAGED_FILES.map(
66
+ (name) =>
67
+ new RegExp(`[/\\\\]\\.(?:pal|claude|agents|cursor)[/\\\\].*${escapeRegExp(name)}$`)
68
+ ),
68
69
  ];
69
70
 
70
71
  /** Patterns that warrant a warning (logged but not blocked) */
@@ -75,38 +76,44 @@ export const WARN_COMMANDS: RegExp[] = [
75
76
  /truncate\s+table/i,
76
77
  ];
77
78
 
79
+ /** Roots where managed files/dirs are protected (user state, not repo templates) */
80
+ const MANAGED_ROOTS = [".pal/", ".claude/", ".agents/", ".config/opencode/", ".cursor/"];
81
+
82
+ function isUnderManagedRoot(path: string): boolean {
83
+ const normalized = path.replace(/\\/g, "/");
84
+ return MANAGED_ROOTS.some(
85
+ (root) => normalized.includes(`/${root}`) || normalized.includes(`\\.${root}`)
86
+ );
87
+ }
88
+
78
89
  /** Read-only commands allowed to reference protected files */
79
90
  const READ_ONLY_COMMANDS =
80
- /^\s*(?:cat|head|tail|less|more|grep|rg|wc|diff|stat|file|ls|dir|git\s+(?:log|diff|blame|show|status)|bat)\b/;
91
+ /^\s*(?:cat|head|tail|less|more|grep|rg|wc|diff|stat|file|ls|dir|find|git\s+(?:log|diff|blame|show|status)|bat)\b/;
81
92
 
82
93
  /** Check a bash command against blocked patterns. Returns reason string or null. */
83
94
  export function checkBashCommand(cmd: string): string | null {
84
95
  for (const [pattern, reason] of BLOCKED_COMMANDS) {
85
96
  if (pattern.test(cmd)) return reason;
86
97
  }
87
- // If command mentions a protected file, block unless it's read-only
98
+ // If command references a managed file in a managed root path, block unless read-only.
99
+ // The filename must appear IN the same path as the managed root (e.g. .pal/.../file.json).
100
+ const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
88
101
  for (const name of HOOK_MANAGED_FILES) {
89
- if (cmd.includes(name)) {
90
- // Check each piped segment — if any segment is not read-only, block
91
- const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
92
- const allReadOnly = segments
93
- .filter((s) => s.includes(name))
94
- .every((s) => READ_ONLY_COMMANDS.test(s));
95
- if (!allReadOnly) {
96
- return `${name} is managed automatically by hooks — do not edit directly`;
97
- }
102
+ const pattern = new RegExp(
103
+ `\\.(?:pal|claude|agents|cursor|config/opencode)[/\\\\]\\S*${escapeRegExp(name)}`
104
+ );
105
+ const managed = segments.filter((s) => pattern.test(s));
106
+ if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
107
+ return `${name} is managed automatically by hooks — do not edit directly`;
98
108
  }
99
109
  }
100
- // If command mentions a hook-managed directory, block unless it's read-only
101
110
  for (const dir of HOOK_MANAGED_DIRS) {
102
- if (cmd.includes(dir)) {
103
- const segments = cmd.split(/[|;&&]/).map((s) => s.trim());
104
- const allReadOnly = segments
105
- .filter((s) => s.includes(dir))
106
- .every((s) => READ_ONLY_COMMANDS.test(s));
107
- if (!allReadOnly) {
108
- return `${dir} is managed automatically by hooks — do not edit directly`;
109
- }
111
+ const pattern = new RegExp(
112
+ `\\.(?:pal|claude|agents|cursor|config/opencode)[/\\\\]\\S*${escapeRegExp(dir)}`
113
+ );
114
+ const managed = segments.filter((s) => pattern.test(s));
115
+ if (managed.length > 0 && !managed.every((s) => READ_ONLY_COMMANDS.test(s))) {
116
+ return `${dir} is managed automatically by hooks — do not edit directly`;
110
117
  }
111
118
  }
112
119
  return null;
@@ -115,10 +122,14 @@ export function checkBashCommand(cmd: string): string | null {
115
122
  /** Check a file path against protected patterns. Returns a reason string or null. */
116
123
  export function checkFilePath(filePath: string): string | null {
117
124
  const normalized = filePath.replace(/\\/g, "/");
118
- // Check hook-managed files first (more specific message)
119
- const matchedFile = HOOK_MANAGED_FILES.find((name) => normalized.endsWith(`/${name}`));
120
- if (matchedFile) {
121
- return `${matchedFile} is managed automatically by hooks — do not edit directly`;
125
+ // Check hook-managed files only under managed roots (not repo templates)
126
+ if (isUnderManagedRoot(normalized)) {
127
+ const matchedFile = HOOK_MANAGED_FILES.find((name) =>
128
+ normalized.endsWith(`/${name}`)
129
+ );
130
+ if (matchedFile) {
131
+ return `${matchedFile} is managed automatically by hooks — do not edit directly`;
132
+ }
122
133
  }
123
134
  // Check hook-managed directories
124
135
  const matchedDir = HOOK_MANAGED_DIRS.find((dir) => normalized.includes(`/${dir}/`));
@@ -8,11 +8,12 @@ import { resolve } from "node:path";
8
8
  import { autoBackup } from "../handlers/backup";
9
9
  import { captureFailure } from "../handlers/failure";
10
10
  import { checkReflectTrigger } from "../handlers/reflect-trigger";
11
- import { captureRelationship } from "../handlers/relationship";
11
+ import { captureSessionIntelligence } from "../handlers/session-intelligence";
12
+ import { runSynthesis } from "../handlers/synthesis";
12
13
  import { resetTab } from "../handlers/tab";
13
14
  import { updateCounts } from "../handlers/update-counts";
14
- import { captureWorkLearning } from "../handlers/work-learning";
15
15
  import { captureWorkSession } from "../handlers/work-session";
16
+ import { inference } from "./inference";
16
17
  import { logDebug, logError } from "./log";
17
18
  import { ensureDir, paths } from "./paths";
18
19
  import { extractContent, extractLastAssistant, parseMessages } from "./transcript";
@@ -39,23 +40,23 @@ export async function runStopHandlers(
39
40
  const results = await Promise.allSettled([
40
41
  captureWorkSession(transcript, options.sessionId),
41
42
  resetTab(),
42
- captureRelationship(transcript, options.sessionId),
43
- captureWorkLearning(transcript, options.sessionId),
43
+ captureSessionIntelligence(transcript, options.sessionId),
44
44
  checkPendingFailure(transcript),
45
45
  updateCounts(),
46
46
  autoBackup(),
47
47
  checkReflectTrigger(),
48
+ runSynthesis(),
48
49
  ]);
49
50
 
50
51
  const handlerNames = [
51
52
  "work-session",
52
53
  "tab",
53
- "relationship",
54
- "work-learning",
54
+ "session-intelligence",
55
55
  "pending-failure",
56
56
  "update-counts",
57
57
  "backup",
58
58
  "reflect-trigger",
59
+ "synthesis",
59
60
  ];
60
61
  for (let i = 0; i < results.length; i++) {
61
62
  const r = results[i];
@@ -137,15 +138,63 @@ async function checkPendingFailure(transcript: string): Promise<void> {
137
138
  rating: number;
138
139
  context: string;
139
140
  detailedContext?: string;
141
+ principle?: string;
140
142
  responsePreview?: string;
141
143
  userPreview?: string;
142
144
  };
143
145
  unlinkSync(pendingPath);
146
+
147
+ // Extract principle from full transcript if not already present
148
+ let { principle, detailedContext } = pending;
149
+ if (!principle) {
150
+ try {
151
+ const msgs = parseMessages(transcript);
152
+ const recent = msgs
153
+ .slice(-10)
154
+ .map((m) => `${m.role.toUpperCase()}: ${extractContent(m).slice(0, 300)}`)
155
+ .join("\n\n");
156
+
157
+ const result = await inference({
158
+ system: `Analyze this failed AI interaction. The user rated it ${pending.rating}/10.
159
+
160
+ Return JSON:
161
+ {
162
+ "principle": "<one actionable rule the AI should follow, 10-20 words. Start with a verb: 'Verify...', 'Always...', 'Never...', 'Ask before...'>",
163
+ "detailed_context": "<what went wrong and why, 50-150 words>"
164
+ }`,
165
+ user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
166
+ maxTokens: 400,
167
+ timeout: 10000,
168
+ jsonSchema: {
169
+ type: "object" as const,
170
+ properties: {
171
+ principle: { type: "string" as const },
172
+ detailed_context: { type: "string" as const },
173
+ },
174
+ required: ["principle", "detailed_context"],
175
+ additionalProperties: false,
176
+ },
177
+ });
178
+
179
+ if (result.success && result.output) {
180
+ const parsed = JSON.parse(result.output) as {
181
+ principle?: string;
182
+ detailed_context?: string;
183
+ };
184
+ principle = parsed.principle || undefined;
185
+ if (!detailedContext) detailedContext = parsed.detailed_context || undefined;
186
+ }
187
+ } catch {
188
+ /* graceful fallback — capture without principle */
189
+ }
190
+ }
191
+
144
192
  await captureFailure(
145
193
  pending.rating,
146
194
  pending.context,
147
195
  transcript,
148
- pending.detailedContext
196
+ detailedContext,
197
+ principle
149
198
  );
150
199
  } catch {
151
200
  // Non-critical
@@ -13,6 +13,7 @@ export type TokenCaller =
13
13
  | "failure"
14
14
  | "work-learning"
15
15
  | "session-name"
16
+ | "session-intelligence"
16
17
  | "relationship";
17
18
 
18
19
  interface TokenUsageEntry {
@@ -58,7 +58,7 @@ copyAgents();
58
58
 
59
59
  // --- Copy PAL system docs ---
60
60
  const palDocsCount = copyPalDocs();
61
- log.success(`Installed ${palDocsCount} PAL docs to ~/.agents/PAL/`);
61
+ log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
62
62
 
63
63
  // --- Scaffold PAL settings ---
64
64
  scaffoldPalSettings();
@@ -9,6 +9,7 @@ import { resolve } from "node:path";
9
9
  import { regenerateIfNeeded } from "../../hooks/lib/claude-md";
10
10
  import { assets, palPkg, platform } from "../../hooks/lib/paths";
11
11
  import {
12
+ copyAgentsForCursor,
12
13
  copyPalDocs,
13
14
  copySkills,
14
15
  countSkills,
@@ -47,9 +48,14 @@ const cursorSkillsDir = resolve(CURSOR_DIR, "skills");
47
48
  copySkills(cursorSkillsDir);
48
49
  generateSkillIndex();
49
50
 
51
+ // --- Copy agents to ~/.cursor/agents/ ---
52
+ const cursorAgentsDir = resolve(CURSOR_DIR, "agents");
53
+ const agentCount = copyAgentsForCursor(cursorAgentsDir);
54
+ if (agentCount > 0) log.success(`Installed ${agentCount} agents to ~/.cursor/agents/`);
55
+
50
56
  // --- Copy PAL system docs ---
51
57
  const palDocsCount = copyPalDocs();
52
- log.success(`Installed ${palDocsCount} PAL docs to ~/.agents/PAL/`);
58
+ log.success(`Installed ${palDocsCount} PAL docs to ~/.pal/docs/`);
53
59
 
54
60
  // --- Scaffold PAL settings ---
55
61
  scaffoldPalSettings();
@@ -11,6 +11,7 @@ import {
11
11
  loadCursorHooksTemplate,
12
12
  log,
13
13
  readJson,
14
+ removeAgentsFromCursor,
14
15
  removePalDocs,
15
16
  removeSkills,
16
17
  unmergeCursorHooks,
@@ -46,6 +47,12 @@ if (removed.length > 0) {
46
47
  log.info("No PAL skills found");
47
48
  }
48
49
 
50
+ // --- Remove PAL agents ---
51
+ const removedAgents = removeAgentsFromCursor(resolve(CURSOR_DIR, "agents"));
52
+ if (removedAgents.length > 0) {
53
+ log.success(`Removed ${removedAgents.length} agent(s): ${removedAgents.join(", ")}`);
54
+ }
55
+
49
56
  // --- Remove PAL system docs ---
50
57
  removePalDocs();
51
58