portable-agent-layer 0.51.1 → 0.52.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/README.md CHANGED
@@ -142,9 +142,16 @@ PAL routes inference through the host agent's subscription CLI by default. API k
142
142
 
143
143
  ### Debug / test
144
144
 
145
+ Enable verbose hook logging with:
146
+
147
+ ```
148
+ pal cli debug on # enable → logs to memory/state/debug.log
149
+ pal cli debug off # disable
150
+ pal cli debug # show current status and log path
151
+ ```
152
+
145
153
  | Variable | Description |
146
154
  |----------|-------------|
147
- | `PAL_DEBUG` | Set to `1` to emit verbose hook logs to `memory/state/debug.log` |
148
155
  | `PAL_INFERENCE_DISABLED` | Set to `1` to disable all inference (used by the test suite to prevent real CLI spawns) |
149
156
  | `PAL_NOTIFICATIONS_DISABLED` | Set to `1` to suppress desktop notifications (used by the test suite) |
150
157
 
@@ -29,7 +29,7 @@ $COST_STR = '$' + ([math]::Round($COST, 2)).ToString("0.00")
29
29
 
30
30
  # PAL: Hook health - count ERROR lines in debug.log from last 24h
31
31
  $HOOK_ERRORS = 0
32
- $debugLog = Join-Path $env:USERPROFILE ".pal\memory\state\debug.log"
32
+ $debugLog = Join-Path $env:USERPROFILE ".pal\debug\debug.log"
33
33
  if (Test-Path $debugLog) {
34
34
  $cutoff = (Get-Date).AddHours(-24)
35
35
  try {
@@ -173,7 +173,7 @@ $QUOTES = @(
173
173
  "A person who never made a mistake never tried anything new.|Albert Einstein"
174
174
  "The journey of a thousand miles begins with one step.|Lao Tzu"
175
175
  "The obstacle is the way.|Marcus Aurelius"
176
- "Whether you think you can or you think you can't you are right.|Henry Ford"
176
+ "Whether you think you can or you think you can't - you are right.|Henry Ford"
177
177
  "Everything should be made as simple as possible, but not simpler.|Albert Einstein"
178
178
  "You miss 100% of the shots you don't take.|Wayne Gretzky"
179
179
  )
@@ -56,7 +56,7 @@ fi
56
56
 
57
57
  # PAL: Hook health — count ERROR lines in debug.log from last 24h
58
58
  HOOK_ERRORS=0
59
- DEBUG_LOG="$HOME/.pal/memory/state/debug.log"
59
+ DEBUG_LOG="$HOME/.pal/debug/debug.log"
60
60
  if [ -f "$DEBUG_LOG" ]; then
61
61
  # Cross-platform 24h cutoff: macOS uses date -v, GNU Linux uses date -d
62
62
  CUTOFF=$(date -v-24H "+%Y-%m-%d %H:%M:%S" 2>/dev/null || date -d '24 hours ago' "+%Y-%m-%d %H:%M:%S" 2>/dev/null)
@@ -209,7 +209,7 @@ QUOTES=(
209
209
  "A person who never made a mistake never tried anything new.|Albert Einstein"
210
210
  "The journey of a thousand miles begins with one step.|Lao Tzu"
211
211
  "The obstacle is the way.|Marcus Aurelius"
212
- "Whether you think you can or you think you can't you are right.|Henry Ford"
212
+ "Whether you think you can or you think you can't - you are right.|Henry Ford"
213
213
  "Everything should be made as simple as possible, but not simpler.|Albert Einstein"
214
214
  "You miss 100% of the shots you don't take.|Wayne Gretzky"
215
215
  )
@@ -21,10 +21,7 @@
21
21
  "opinions": true,
22
22
  "relationship": true,
23
23
  "learningDigest": true,
24
- "synthesis": true,
25
- "signalTrends": true,
26
24
  "failurePatterns": true,
27
- "activeWork": true,
28
25
  "projectHistory": true,
29
26
  "projects": true,
30
27
  "sessionIntelligence": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.51.1",
3
+ "version": "0.52.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/index.ts CHANGED
@@ -18,14 +18,23 @@
18
18
  * usage Summarize token usage and cost
19
19
  * skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
20
20
  * skill doctor <name> Evaluate a skill against the authoring best practices
21
+ * debug [on|off] Enable / disable verbose hook debug logging
21
22
  */
22
23
 
23
24
  import { spawnSync } from "node:child_process";
24
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
25
+ import {
26
+ existsSync,
27
+ mkdirSync,
28
+ readdirSync,
29
+ readFileSync,
30
+ rmSync,
31
+ statSync,
32
+ writeFileSync,
33
+ } from "node:fs";
25
34
  import { homedir } from "node:os";
26
35
  import { resolve } from "node:path";
27
36
  import { inference, previewInferenceRoute } from "../hooks/lib/inference";
28
- import { DEBUG_LOG_MAX_ROTATED } from "../hooks/lib/log";
37
+ import { DEBUG_LOG_MAX_ROTATED, logDebug } from "../hooks/lib/log";
29
38
  import { palHome, palPkg, platform } from "../hooks/lib/paths";
30
39
  import { hasRealContent, SETUP_STEPS, STEP_ORDER } from "../hooks/lib/setup";
31
40
  import { log } from "../targets/lib";
@@ -200,6 +209,9 @@ async function runCli(command: string | undefined, args: string[]) {
200
209
  if (code !== 0) process.exit(code);
201
210
  break;
202
211
  }
212
+ case "debug":
213
+ cliDebug(args);
214
+ break;
203
215
  case "--help":
204
216
  case "-h":
205
217
  case "help":
@@ -244,6 +256,7 @@ function showHelp() {
244
256
  (search · graph · stats · hubs · find · show · add · ls)
245
257
  pal cli skill link <name> Link a personal ~/.pal/skills/<name>/ into installed agents
246
258
  pal cli skill doctor <name> Evaluate a skill against the authoring best practices
259
+ pal cli debug [on|off] Enable/disable verbose hook debug logging (persisted)
247
260
 
248
261
  Environment:
249
262
  PAL_HOME Override user state directory (default: ~/.pal or repo root)
@@ -1151,10 +1164,19 @@ async function exportState(args: string[]) {
1151
1164
 
1152
1165
  const dryRun = args.includes("--dry-run");
1153
1166
  const pathArg = args.find((a) => !a.startsWith("-"));
1154
- const outputPath = pathArg || resolve(palHome(), `pal-export-${timestamp()}.zip`);
1155
-
1167
+ const resolvedArg = pathArg ? resolve(pathArg) : null;
1168
+ const argIsDir =
1169
+ resolvedArg !== null &&
1170
+ existsSync(resolvedArg) &&
1171
+ statSync(resolvedArg).isDirectory();
1172
+ const outputPath = argIsDir
1173
+ ? resolve(resolvedArg, `pal-export-${timestamp()}.zip`)
1174
+ : (resolvedArg ?? resolve(palHome(), `pal-export-${timestamp()}.zip`));
1175
+
1176
+ logDebug("export", `start dryRun=${dryRun} outputPath=${outputPath}`);
1156
1177
  if (dryRun) {
1157
1178
  const files = collectExportFiles();
1179
+ logDebug("export", `dry-run collected ${files.length} files`);
1158
1180
  if (files.length === 0) {
1159
1181
  console.log("Nothing to export.");
1160
1182
  } else {
@@ -1163,6 +1185,7 @@ async function exportState(args: string[]) {
1163
1185
  }
1164
1186
  } else {
1165
1187
  const count = exportZip(outputPath);
1188
+ logDebug("export", `done count=${count} path=${outputPath}`);
1166
1189
  if (count === 0) {
1167
1190
  console.log("Nothing to export.");
1168
1191
  } else {
@@ -1179,49 +1202,50 @@ async function importState(args: string[]) {
1179
1202
  const home = palHome();
1180
1203
  const dryRun = args.includes("--dry-run");
1181
1204
  const pathArg = args.find((a) => !a.startsWith("-"));
1205
+ logDebug("import", `start dryRun=${dryRun} pathArg=${pathArg ?? "(auto)"}`);
1182
1206
 
1183
- function findLatest(): string | null {
1207
+ function findLatestIn(dirs: string[]): string | null {
1184
1208
  const candidates: string[] = [];
1185
-
1186
- try {
1187
- candidates.push(
1188
- ...readdirSync(home)
1189
- .filter((f) => f.startsWith("pal-export-") && f.endsWith(".zip"))
1190
- .map((f) => resolve(home, f))
1191
- );
1192
- } catch {
1193
- /* empty */
1194
- }
1195
-
1196
- try {
1197
- const backupDir = resolve(home, "backups");
1198
- candidates.push(
1199
- ...readdirSync(backupDir)
1200
- .filter(
1201
- (f) =>
1202
- (f.startsWith("pal-export-") || f.startsWith("pal-backup-")) &&
1203
- f.endsWith(".zip")
1204
- )
1205
- .map((f) => resolve(backupDir, f))
1206
- );
1207
- } catch {
1208
- /* empty */
1209
+ for (const dir of dirs) {
1210
+ try {
1211
+ candidates.push(
1212
+ ...readdirSync(dir)
1213
+ .filter(
1214
+ (f) =>
1215
+ (f.startsWith("pal-export-") || f.startsWith("pal-backup-")) &&
1216
+ f.endsWith(".zip")
1217
+ )
1218
+ .map((f) => resolve(dir, f))
1219
+ );
1220
+ } catch {
1221
+ /* empty */
1222
+ }
1209
1223
  }
1210
-
1211
1224
  if (candidates.length === 0) return null;
1212
1225
  return candidates.sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs)[0];
1213
1226
  }
1214
1227
 
1215
1228
  let zipPath: string;
1216
1229
 
1217
- if (pathArg) {
1218
- zipPath = resolve(pathArg);
1230
+ const resolvedArg = pathArg ? resolve(pathArg) : null;
1231
+ const argIsDir =
1232
+ resolvedArg !== null &&
1233
+ existsSync(resolvedArg) &&
1234
+ statSync(resolvedArg).isDirectory();
1235
+
1236
+ if (resolvedArg && !argIsDir) {
1237
+ zipPath = resolvedArg;
1238
+ logDebug("import", `using provided path=${zipPath}`);
1219
1239
  } else {
1220
- const latest = findLatest();
1240
+ const searchDirs = argIsDir ? [resolvedArg] : [home, resolve(home, "backups")];
1241
+ logDebug("import", `searching dirs=${searchDirs.join(",")}`);
1242
+ const latest = findLatestIn(searchDirs);
1221
1243
  if (!latest) {
1244
+ logDebug("import", "no export/backup files found");
1222
1245
  log.error("No export or backup files found. Provide a path: pal cli import <path>");
1223
1246
  process.exit(1);
1224
1247
  }
1248
+ logDebug("import", `auto-selected latest=${latest}`);
1225
1249
  console.log(`Found: ${latest}`);
1226
1250
  const zip = new AdmZip(latest);
1227
1251
  console.log(
@@ -1253,11 +1277,13 @@ async function importState(args: string[]) {
1253
1277
  process.exit(0);
1254
1278
  }
1255
1279
 
1280
+ logDebug("import", `zip=${zipPath} entries=${entries.length} dryRun=${dryRun}`);
1256
1281
  if (dryRun) {
1257
1282
  console.log(`Would import ${entries.length} files → ${home}\n`);
1258
1283
  for (const e of entries) console.log(` ${e.entryName}`);
1259
1284
  } else {
1260
1285
  zip.extractAllTo(home, true);
1286
+ logDebug("import", `done extracted=${entries.length} to=${home}`);
1261
1287
  console.log(`Imported ${entries.length} files → ${home}`);
1262
1288
  log.info("Run 'pal cli install' to re-register hooks.");
1263
1289
  }
@@ -1309,6 +1335,25 @@ async function update() {
1309
1335
  await install(resolveTargets([]));
1310
1336
  }
1311
1337
 
1338
+ function cliDebug(args: string[]) {
1339
+ const stateDir = resolve(palHome(), "memory", "state");
1340
+ const flagFile = resolve(stateDir, "debug-enabled");
1341
+ const logFile = resolve(stateDir, "debug.log");
1342
+ const sub = args[0];
1343
+ if (sub === "on") {
1344
+ mkdirSync(stateDir, { recursive: true });
1345
+ writeFileSync(flagFile, "");
1346
+ log.success(`Debug logging enabled → ${logFile}`);
1347
+ } else if (sub === "off") {
1348
+ rmSync(flagFile, { force: true });
1349
+ log.success("Debug logging disabled");
1350
+ } else {
1351
+ const on = existsSync(flagFile);
1352
+ console.log(` Debug: ${on ? "ON" : "OFF"}`);
1353
+ console.log(` Log: ${logFile}`);
1354
+ }
1355
+ }
1356
+
1312
1357
  async function status() {
1313
1358
  const home = palHome();
1314
1359
  const pkg = palPkg();
@@ -14,7 +14,7 @@ import { resolve } from "node:path";
14
14
  import { getActiveAgent, isCodex, isCopilot, isCursor } from "./lib/agent";
15
15
  import { buildClaudeMd, regenerateIfNeeded } from "./lib/claude-md";
16
16
  import { type AgentTarget, buildSystemReminder } from "./lib/context";
17
- import { logDebug, logError } from "./lib/log";
17
+ import { logContextSnapshot, logDebug, logError } from "./lib/log";
18
18
  import { platform } from "./lib/paths";
19
19
  import { isPalSpawnedInference } from "./lib/spawn-guard";
20
20
 
@@ -48,6 +48,7 @@ try {
48
48
  active === "copilot" || active === "cursor" ? active : "claude";
49
49
  const reminder = buildSystemReminder({ agent });
50
50
  if (!reminder) process.exit(0);
51
+ logContextSnapshot(reminder);
51
52
 
52
53
  if (isCopilot()) {
53
54
  // Copilot: semi-static in ~/.copilot/instructions/pal-*.instructions.md (written at stop).
@@ -10,7 +10,7 @@
10
10
  import { injectRetrieval } from "./handlers/inject-retrieval";
11
11
  import { captureRating } from "./handlers/rating";
12
12
  import { captureSessionName } from "./handlers/session-name";
13
- import { logDebug, logError } from "./lib/log";
13
+ import { logDebug, logError, logPromptSnapshot } from "./lib/log";
14
14
  import { isPalSpawnedInference } from "./lib/spawn-guard";
15
15
  import { readStdinJSON } from "./lib/stdin";
16
16
 
@@ -30,13 +30,15 @@ logDebug("UserPromptOrchestrator", `Input: ${JSON.stringify(input).slice(0, 200)
30
30
  if (!input?.prompt) process.exit(0);
31
31
 
32
32
  const sessionId = input.session_id ?? input.sessionId ?? input.conversation_id;
33
+ const retrieval = await injectRetrieval(input.prompt);
34
+ logPromptSnapshot(input.prompt, retrieval);
35
+
33
36
  const results = await Promise.allSettled([
34
37
  captureRating(input.prompt, sessionId),
35
38
  captureSessionName(input.prompt, sessionId ?? ""),
36
- injectRetrieval(input.prompt),
37
39
  ]);
38
40
 
39
- const handlerNames = ["rating", "session-name", "inject-retrieval"];
41
+ const handlerNames = ["rating", "session-name"];
40
42
  for (let i = 0; i < results.length; i++) {
41
43
  const r = results[i];
42
44
  if (r.status === "rejected") {
@@ -52,10 +52,11 @@ export async function getRetrievalReminder(prompt: string): Promise<string | nul
52
52
  }
53
53
 
54
54
  /** Write retrieval reminder to stdout in the correct format for the current agent.
55
- * Claude Code: plain text. Cursor: { additional_context }. Codex: hookSpecificOutput JSON. */
56
- export async function injectRetrieval(prompt: string): Promise<void> {
55
+ * Claude Code: plain text. Cursor: { additional_context }. Codex: hookSpecificOutput JSON.
56
+ * Returns the reminder string that was injected, or null if nothing was injected. */
57
+ export async function injectRetrieval(prompt: string): Promise<string | null> {
57
58
  const reminder = await getRetrievalReminder(prompt);
58
- if (!reminder) return;
59
+ if (!reminder) return null;
59
60
  if (isCursor()) {
60
61
  process.stdout.write(JSON.stringify({ additional_context: reminder }));
61
62
  } else if (isCodex()) {
@@ -70,4 +71,5 @@ export async function injectRetrieval(prompt: string): Promise<void> {
70
71
  } else {
71
72
  process.stdout.write(`${reminder}\n`);
72
73
  }
74
+ return reminder;
73
75
  }
@@ -64,7 +64,7 @@ export function parseExplicitRating(
64
64
  // Reject if rest starts with words indicating a sentence, not a rating
65
65
  if (rest) {
66
66
  const sentenceStarters =
67
- /^(items?|things?|steps?|files?|lines?|bugs?|issues?|errors?|times?|minutes?|hours?|days?|seconds?|percent|%|th\b|st\b|nd\b|rd\b|of\b|in\b|at\b|to\b|the\b|a\b|an\b)/i;
67
+ /^(items?|things?|steps?|files?|lines?|bugs?|issues?|errors?|times?|minutes?|hours?|days?|seconds?|percent|%|th\b|st\b|nd\b|rd\b|of\b|in\b|at\b|to\b|the\b|a\b|an\b|then\b|also\b|next\b)/i;
68
68
  if (sentenceStarters.test(rest)) return null;
69
69
 
70
70
  // Reject item selections: "1 and 2", "2 3 5", "1, 3, 5", "1-3"
@@ -12,9 +12,8 @@ import { loadOpinionContext } from "./opinions";
12
12
  import { paths } from "./paths";
13
13
  import { loadActiveProjectsContext } from "./projects";
14
14
  import { loadRecentNotes } from "./relationship";
15
- import { loadFailurePatterns, loadSynthesisRecommendations } from "./semi-static";
15
+ import { loadFailurePatterns } from "./semi-static";
16
16
  import * as settings from "./settings";
17
- import { computeSignalTrends, formatTrends } from "./signal-trends";
18
17
  import { readFramePrinciples } from "./wisdom";
19
18
  import { readProjectHistory } from "./work-tracking";
20
19
 
@@ -87,15 +86,6 @@ function loadSelfModel(): string {
87
86
  }
88
87
  }
89
88
 
90
- /** Load signal trends as a formatted string */
91
- export function loadSignalTrends(): string {
92
- try {
93
- return formatTrends(computeSignalTrends());
94
- } catch {
95
- return "";
96
- }
97
- }
98
-
99
89
  /** Load per-project session history for the current working directory */
100
90
  function loadProjectHistoryContext(): string {
101
91
  try {
@@ -298,13 +288,8 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
298
288
  const activeProjects = settings.isEnabled("projects")
299
289
  ? loadActiveProjectsContext()
300
290
  : "";
301
- const trends = settings.isEnabled("signalTrends") ? loadSignalTrends() : "";
302
291
  const failures =
303
292
  settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
304
- const synthesis =
305
- settings.isEnabled("synthesis") && !skipSemiStatic
306
- ? loadSynthesisRecommendations()
307
- : "";
308
293
  const opinions =
309
294
  !skipSemiStatic && settings.isEnabled("opinions") ? loadOpinionContext() : "";
310
295
  const selfModel =
@@ -327,8 +312,6 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
327
312
  if (activeProjects) parts.push(activeProjects);
328
313
  if (projectHistory) parts.push(projectHistory);
329
314
  if (digest) parts.push(digest);
330
- if (synthesis) parts.push(synthesis);
331
- if (trends) parts.push(trends);
332
315
  if (failures) parts.push(failures);
333
316
  if (parts.length === 0) return "";
334
317
 
@@ -2,19 +2,26 @@
2
2
  * Simple file-based debug logger for PAL hooks.
3
3
  * Writes to memory/state/debug.log — rotated on each session start.
4
4
  *
5
- * Only writes when PAL_DEBUG=1 or when called via logError (always logged).
5
+ * Only writes when debug is enabled (`pal cli debug on`) or when called via logError (always logged).
6
6
  */
7
7
 
8
- import { appendFileSync, existsSync, renameSync, statSync, unlinkSync } from "node:fs";
8
+ import {
9
+ appendFileSync,
10
+ existsSync,
11
+ renameSync,
12
+ statSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
9
16
  import { resolve } from "node:path";
10
- import { paths } from "./paths";
17
+ import { palHome, paths } from "./paths";
11
18
 
12
19
  const MAX_LOG_SIZE = 50_000; // ~50KB per file
13
20
  const MAX_ROTATED = 5; // keep up to 5 rotated files (.1 newest → .5 oldest)
14
21
 
15
22
  /** Resolved lazily so PAL_HOME overrides at runtime are honored. */
16
23
  function logFile(): string {
17
- return resolve(paths.state(), "debug.log");
24
+ return resolve(paths.debug(), "debug.log");
18
25
  }
19
26
 
20
27
  function timestamp(): string {
@@ -74,9 +81,13 @@ function rotateIfNeeded(path: string): void {
74
81
  /** Test-only: max rotated count for callers that need to enumerate. */
75
82
  export const DEBUG_LOG_MAX_ROTATED = MAX_ROTATED;
76
83
 
77
- /** Log a debug message (only when PAL_DEBUG=1) */
84
+ function isDebugEnabled(): boolean {
85
+ return existsSync(resolve(palHome(), "memory", "state", "debug-enabled"));
86
+ }
87
+
88
+ /** Log a debug message (only when debug is enabled via `pal cli debug on`) */
78
89
  export function logDebug(source: string, message: string): void {
79
- if (process.env.PAL_DEBUG !== "1") return;
90
+ if (!isDebugEnabled()) return;
80
91
  const path = logFile();
81
92
  rotateIfNeeded(path);
82
93
  try {
@@ -86,7 +97,32 @@ export function logDebug(source: string, message: string): void {
86
97
  }
87
98
  }
88
99
 
89
- /** Log an error (always written, regardless of PAL_DEBUG) */
100
+ /** Write last user prompt + retrieval injection to last-prompt.md (only when debug is enabled) */
101
+ export function logPromptSnapshot(prompt: string, retrieval: string | null): void {
102
+ if (!isDebugEnabled()) return;
103
+ const path = resolve(paths.debug(), "last-prompt.md");
104
+ const content = retrieval
105
+ ? `## Prompt\n\n${prompt}\n\n## Retrieval Injection\n\n${retrieval}`
106
+ : `## Prompt\n\n${prompt}`;
107
+ try {
108
+ writeFileSync(path, content, "utf-8");
109
+ } catch {
110
+ /* non-critical */
111
+ }
112
+ }
113
+
114
+ /** Write full context snapshot to context-snapshot.md (only when debug is enabled) */
115
+ export function logContextSnapshot(content: string): void {
116
+ if (!isDebugEnabled()) return;
117
+ const path = resolve(paths.debug(), "context-snapshot.md");
118
+ try {
119
+ writeFileSync(path, content, "utf-8");
120
+ } catch {
121
+ /* non-critical */
122
+ }
123
+ }
124
+
125
+ /** Log an error (always written, regardless of debug mode) */
90
126
  export function logError(source: string, error: unknown): void {
91
127
  const path = logFile();
92
128
  rotateIfNeeded(path);
@@ -60,6 +60,7 @@ export const paths = {
60
60
  synthesis: () => ensureDir(home("memory", "learning", "synthesis")),
61
61
  work: () => ensureDir(home("memory", "work")),
62
62
  backups: () => ensureDir(home("backups")),
63
+ debug: () => ensureDir(home("debug")),
63
64
  } as const;
64
65
 
65
66
  // Platform directories (env override or cross-platform defaults)
@@ -358,17 +358,6 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
358
358
  if (p.blockers?.length) {
359
359
  lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
360
360
  }
361
- if (p.criteria) {
362
- const openIscs = p.criteria
363
- .split("\n")
364
- .filter((l) => /^-\s+\[ \]\s+ISC-\d+:/i.test(l))
365
- .map((l) => l.replace(/^-\s+\[ \]\s+/, "").trim());
366
- if (openIscs.length > 0) {
367
- lines.push(
368
- ` Open ISCs (${openIscs.length}): ${openIscs.slice(0, MAX_INLINE_BULLETS).join("; ")}`
369
- );
370
- }
371
- }
372
361
  } else {
373
362
  const counts: string[] = [];
374
363
  if (p.next?.length) counts.push(`${p.next.length} next`);
@@ -24,19 +24,12 @@ const HOOK_MANAGED_FILES = [
24
24
  "captured-learnings.json",
25
25
  "counts.json",
26
26
  "session-names.json",
27
- "debug.log",
28
27
  "last-responses.json",
29
28
  "signal-cache.json",
30
29
  "pending-failure.json",
31
30
  "token-usage.jsonl",
32
31
  "graduated.json",
33
32
  "update-available.json",
34
- "debug.log.prev",
35
- "debug.log.1",
36
- "debug.log.2",
37
- "debug.log.3",
38
- "debug.log.4",
39
- "debug.log.5",
40
33
  "opinions.json",
41
34
  "pal-settings.json",
42
35
  "skill-index.json",
@@ -54,6 +47,7 @@ const HOOK_MANAGED_DIRS = [
54
47
  "memory/wisdom/state",
55
48
  "memory/projects",
56
49
  "memory/state/progress",
50
+ "debug",
57
51
  ];
58
52
 
59
53
  /** Escape a string for use in a RegExp */
@@ -6,9 +6,8 @@
6
6
  * and the session-stop digest writer.
7
7
  */
8
8
 
9
- import { existsSync, readdirSync, readFileSync } from "node:fs";
9
+ import { existsSync, readFileSync } from "node:fs";
10
10
  import { resolve } from "node:path";
11
- import { parse } from "./frontmatter";
12
11
  import { type FailureEntry, readFailures } from "./learning-store";
13
12
  import { loadOpinionContext } from "./opinions";
14
13
  import { palHome, paths } from "./paths";
@@ -47,59 +46,6 @@ function readFileSafe(path: string): string {
47
46
  }
48
47
  }
49
48
 
50
- /** Build recommendations from the most recent synthesis report. */
51
- export function loadSynthesisRecommendations(): string {
52
- try {
53
- const synthDir = paths.synthesis();
54
- if (!existsSync(synthDir)) return "";
55
-
56
- const months = readdirSync(synthDir).sort().reverse();
57
- for (const month of months) {
58
- const monthDir = resolve(synthDir, month);
59
- try {
60
- const files = readdirSync(monthDir)
61
- .filter((f) => f.endsWith(".md"))
62
- .sort()
63
- .reverse();
64
- if (files.length === 0) continue;
65
-
66
- const content = readFileSync(resolve(monthDir, files[0]), "utf-8");
67
-
68
- const recMatch = new RegExp(
69
- /## Recommendations\n\n([\s\S]*?)(?:\n##|\n$|$)/
70
- ).exec(content);
71
- if (!recMatch?.[1]?.trim()) continue;
72
-
73
- const recs = recMatch[1]
74
- .trim()
75
- .split("\n")
76
- .filter((l) => l.trim())
77
- .slice(0, 4);
78
-
79
- if (recs.length === 0) continue;
80
-
81
- const { meta } = parse<{ period?: string; average_rating?: string }>(content);
82
- const period = meta.period ?? "";
83
- const avgRating = meta.average_rating ? `${meta.average_rating}/10` : "";
84
-
85
- const header = [
86
- "## Pattern Synthesis",
87
- period ? `*${period} — ${avgRating}*` : "",
88
- ]
89
- .filter(Boolean)
90
- .join("\n");
91
-
92
- return [header, ...recs].join("\n");
93
- } catch {
94
- /* try next month */
95
- }
96
- }
97
- return "";
98
- } catch {
99
- return "";
100
- }
101
- }
102
-
103
49
  /**
104
50
  * Rank failures for injection: same-project (cwd match) first, then by recency.
105
51
  * Project-relevant lessons are never crowded out by more-recent other-project
@@ -186,13 +132,6 @@ export function getSemiStaticSources(): SemiStaticSource[] {
186
132
  slug: "opinions",
187
133
  description: "PAL opinions",
188
134
  },
189
- {
190
- path: resolve(memory, "learning", "synthesis-digest.md"),
191
- writesDigest: true,
192
- load: loadSynthesisRecommendations,
193
- slug: "synthesis",
194
- description: "PAL pattern synthesis",
195
- },
196
135
  {
197
136
  path: resolve(memory, "learning", "failures-digest.md"),
198
137
  writesDigest: true,
@@ -98,17 +98,34 @@ export function loadSettingsTemplate(templatePath: string, pkgRoot: string): Set
98
98
  export function mergeSettings(existing: Settings, template: Settings): Settings {
99
99
  const result = { ...existing };
100
100
 
101
- // Merge hooks (deduplicate by command)
101
+ // Merge hooks strip old-path PAL entries first, then insert current template entries.
102
+ // This handles reinstalling from a different path (repo vs global install) without
103
+ // leaving orphan hook entries that fire twice per event.
102
104
  if (template.hooks) {
103
105
  result.hooks ??= {};
104
- for (const [event, entries] of Object.entries(template.hooks)) {
105
- const current = result.hooks[event] ?? [];
106
+
107
+ // Collect canonical forms of all template hook commands
108
+ const palCanonical = new Set<string>();
109
+ for (const entries of Object.values(template.hooks)) {
106
110
  for (const entry of entries) {
107
111
  const cmd = entry.hooks?.[0]?.command;
108
- if (cmd && !current.some((e) => e.hooks?.[0]?.command === cmd)) {
109
- current.push(entry);
110
- }
112
+ if (cmd) palCanonical.add(canonicalPalCmd(cmd));
111
113
  }
114
+ }
115
+
116
+ // Strip existing PAL hooks that match canonically (removes old-path duplicates)
117
+ for (const [event, entries] of Object.entries(result.hooks)) {
118
+ result.hooks[event] = entries.filter((e) => {
119
+ const cmd = e.hooks?.[0]?.command;
120
+ return !cmd || !palCanonical.has(canonicalPalCmd(cmd));
121
+ });
122
+ if (result.hooks[event].length === 0) delete result.hooks[event];
123
+ }
124
+
125
+ // Insert template entries (old-path versions were just stripped)
126
+ for (const [event, entries] of Object.entries(template.hooks)) {
127
+ const current = result.hooks[event] ?? [];
128
+ for (const entry of entries) current.push(entry);
112
129
  result.hooks[event] = current;
113
130
  }
114
131
  }
@@ -195,20 +212,20 @@ export function mergeSettings(existing: Settings, template: Settings): Settings
195
212
  export function unmergeSettings(existing: Settings, template: Settings): Settings {
196
213
  const result = { ...existing };
197
214
 
198
- // Collect all PAL hook commands from template
215
+ // Collect canonical PAL hook commands from template (path-normalized)
199
216
  if (template.hooks && result.hooks) {
200
- const palCommands = new Set<string>();
217
+ const palCanonical = new Set<string>();
201
218
  for (const entries of Object.values(template.hooks)) {
202
219
  for (const entry of entries) {
203
220
  const cmd = entry.hooks?.[0]?.command;
204
- if (cmd) palCommands.add(cmd);
221
+ if (cmd) palCanonical.add(canonicalPalCmd(cmd));
205
222
  }
206
223
  }
207
224
 
208
225
  for (const [event, entries] of Object.entries(result.hooks)) {
209
226
  result.hooks[event] = entries.filter((e) => {
210
227
  const cmd = e.hooks?.[0]?.command;
211
- return !cmd || !palCommands.has(cmd);
228
+ return !cmd || !palCanonical.has(canonicalPalCmd(cmd));
212
229
  });
213
230
  if (result.hooks[event].length === 0) delete result.hooks[event];
214
231
  }
@@ -377,9 +394,17 @@ type CodexHookCommand = { type: string; command: string; timeout?: number };
377
394
  type CodexHookGroup = { matcher?: string; hooks: CodexHookCommand[] };
378
395
  type CodexHooks = { hooks?: Record<string, CodexHookGroup[]> };
379
396
 
380
- /** Strip leading env-var assignments so "PAL_AGENT=x bun run ..." → "bun run ..." */
381
- function canonicalCmd(cmd: string): string {
382
- return cmd.replace(/^(?:\w+=\S+\s+)+/, "");
397
+ /**
398
+ * Normalize a PAL hook command for cross-path deduplication.
399
+ * Strips env-var prefix and normalizes the hook file path so reinstalling from
400
+ * a different location (repo vs global install) correctly strips old entries.
401
+ * "PAL_AGENT=x bun run /any/path/src/hooks/Foo.ts" → "bun run src/hooks/Foo.ts"
402
+ */
403
+ function canonicalPalCmd(cmd: string): string {
404
+ const withoutEnv = cmd.replace(/^(?:\w+=\S+\s+)+/, "");
405
+ const hookMatch = /bun\s+run\s+.+\/src\/hooks\/(\S+)/.exec(withoutEnv);
406
+ if (hookMatch) return `bun run src/hooks/${hookMatch[1]}`;
407
+ return withoutEnv;
383
408
  }
384
409
 
385
410
  export function loadCodexHooksTemplate(
@@ -399,7 +424,7 @@ export function loadCodexHooksTemplate(
399
424
  function collectPalCanonical(template: CodexHooks): Set<string> {
400
425
  return new Set(
401
426
  Object.values(template.hooks ?? {}).flatMap((groups) =>
402
- groups.flatMap((g) => g.hooks.map((h) => canonicalCmd(h.command)))
427
+ groups.flatMap((g) => g.hooks.map((h) => canonicalPalCmd(h.command)))
403
428
  )
404
429
  );
405
430
  }
@@ -413,11 +438,11 @@ function stripPalHooks(
413
438
  hooks[event] = (hooks[event] ?? [])
414
439
  .map((g) => {
415
440
  const flat = g as unknown as CodexHookCommand;
416
- if (!g.hooks && flat.command && palCanonical.has(canonicalCmd(flat.command))) {
441
+ if (!g.hooks && flat.command && palCanonical.has(canonicalPalCmd(flat.command))) {
417
442
  return null;
418
443
  }
419
444
  const filtered = (g.hooks ?? []).filter(
420
- (h) => !palCanonical.has(canonicalCmd(h.command))
445
+ (h) => !palCanonical.has(canonicalPalCmd(h.command))
421
446
  );
422
447
  return filtered.length > 0 ? { ...g, hooks: filtered } : null;
423
448
  })
@@ -30,7 +30,7 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
30
30
  await lib<typeof import("../../hooks/lib/context")>("context.ts");
31
31
  const { checkBashCommand, checkFilePath } =
32
32
  await lib<typeof import("../../hooks/lib/security")>("security.ts");
33
- const { logDebug, logError } =
33
+ const { logDebug, logError, logPromptSnapshot } =
34
34
  await lib<typeof import("../../hooks/lib/log")>("log.ts");
35
35
 
36
36
  // Load shared handlers
@@ -139,19 +139,21 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
139
139
  const text = partsToText(output.parts ?? []);
140
140
  if (!text.trim()) return;
141
141
 
142
- const [, , retrievalResult] = await Promise.allSettled([
142
+ const retrieval = await getRetrievalReminder(text);
143
+ logPromptSnapshot(text, retrieval);
144
+
145
+ await Promise.allSettled([
143
146
  captureRating(text, input.sessionID),
144
147
  captureSessionName(text, input.sessionID),
145
- getRetrievalReminder(text),
146
148
  ]);
147
149
 
148
- if (retrievalResult.status === "fulfilled" && retrievalResult.value) {
150
+ if (retrieval) {
149
151
  const injected = {
150
152
  id: `pal-retrieval-${Date.now()}`,
151
153
  sessionID: input.sessionID,
152
154
  messageID: input.messageID ?? `pal-msg-${Date.now()}`,
153
155
  type: "text" as const,
154
- text: retrievalResult.value,
156
+ text: retrieval,
155
157
  synthetic: true,
156
158
  };
157
159
  output.parts = [injected, ...(output.parts ?? [])];
@@ -192,9 +194,6 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
192
194
  output: { env: Record<string, string> }
193
195
  ) => {
194
196
  output.env.PAL_DIR = PAL_DIR;
195
- if (process.env.PAL_DEBUG) {
196
- output.env.PAL_DEBUG = process.env.PAL_DEBUG;
197
- }
198
197
  },
199
198
  };
200
199
  };
@@ -242,9 +242,49 @@ function getRecentSessions(since: Date): {
242
242
  export function writeSynthesis(state: SynthesisState): string {
243
243
  const sp = synthesisPath();
244
244
  writeFileSync(sp, JSON.stringify(state, null, 2), "utf-8");
245
+ writeSignalCache();
245
246
  return sp;
246
247
  }
247
248
 
249
+ function writeSignalCache(): void {
250
+ try {
251
+ const p = resolve(paths.signals(), "ratings.jsonl");
252
+ const all = readJsonl<Rating>(p);
253
+ const now = new Date();
254
+ const todayStr = now.toISOString().slice(0, 10);
255
+
256
+ const avg = (rs: Rating[]) =>
257
+ rs.length === 0 ? null : round1(rs.reduce((s, r) => s + r.rating, 0) / rs.length);
258
+
259
+ const today = avg(all.filter((r) => r.ts.slice(0, 10) === todayStr));
260
+ const week = avg(all.filter((r) => new Date(r.ts) >= daysAgo(7)));
261
+ const month = avg(all.filter((r) => new Date(r.ts) >= daysAgo(30)));
262
+
263
+ const recent = all.slice(-20);
264
+ const mid = Math.floor(recent.length / 2);
265
+ let trend: "up" | "down" | "stable" = "stable";
266
+ if (mid >= 3) {
267
+ const a = avg(recent.slice(0, mid)) ?? 0;
268
+ const b = avg(recent.slice(mid)) ?? 0;
269
+ if (b - a > 0.5) trend = "up";
270
+ else if (a - b > 0.5) trend = "down";
271
+ }
272
+
273
+ const cache = resolve(paths.state(), "signal-cache.json");
274
+ writeFileSync(
275
+ cache,
276
+ JSON.stringify(
277
+ { computed_at: now.toISOString(), today, week, month, trend },
278
+ null,
279
+ 2
280
+ ),
281
+ "utf-8"
282
+ );
283
+ } catch {
284
+ /* non-critical */
285
+ }
286
+ }
287
+
248
288
  export function synthesize(days: number): SynthesisState {
249
289
  const since = daysAgo(days);
250
290
  const { sessions, count: sessionCount } = getRecentSessions(since);
@@ -474,40 +474,27 @@ function formatDataForInference(data: SelfModelData): string {
474
474
  function buildPrompt(aiName: string, principalName: string): string {
475
475
  return `You are writing a self-model for an AI assistant named ${aiName}. You ARE ${aiName}. Write in first person.
476
476
 
477
- You will receive structured data about your performance, your user's preferences, your strengths, weaknesses, and behavioral patterns over a time window.
477
+ You will receive structured data about your performance, your user's preferences, and behavioral patterns over a time window.
478
478
 
479
- Synthesize this into a genuine self-portrait. Not a data dump a reflection. The goal is self-awareness that changes behavior.
479
+ Produce a short, actionable self-model not a data dump. Every sentence must change behavior, not just describe it.
480
480
 
481
481
  ## Required Sections
482
482
 
483
483
  **# Self-Model — ${aiName}**
484
484
  Include synthesis date and window.
485
485
 
486
- **## Who I Am**
487
- One paragraph. Your identity, your role, your current performance level. Be honest about the numbers.
486
+ **## Who ${principalName} Is**
487
+ One paragraph. Synthesize the opinions and behavioral notes into a working portrait — how ${principalName} thinks, communicates, and what frustrates him. Do not list raw opinion statements. Write it as understanding, not inventory.
488
488
 
489
- **## What I Know About ${principalName}**
490
- Synthesize the opinions into understanding. Don't list them describe who ${principalName} is as a person to work with. What does he value? How does he communicate? What frustrates him?
491
-
492
- **## My Strengths**
493
- What you're genuinely good at, based on evidence. High-rated interactions, crystallized principles, successful patterns. Distinguish domain knowledge from behavioral strengths.
494
-
495
- **## My Weaknesses**
496
- What you repeatedly get wrong. Synthesize failure patterns and low ratings into honest self-knowledge. Name the root causes, not just the symptoms.
497
-
498
- **## My Tendencies**
499
- Behavioral patterns — not one-off events. What do you tend to do? Over-promise? Rush verification? Propose before checking? Synthesize from the self-observations and behavioral notes.
500
-
501
- **## Trajectory**
502
- Where are you heading? Improving, declining, stagnating? What's the single most impactful thing you could change right now?
489
+ **## My Priority Right Now**
490
+ One sentence. The single most impactful behavioral change to make immediately, derived from the failure patterns and trajectory. Specific and actionable not "be more careful" but "before generating output that names a command or path, verify it exists."
503
491
 
504
492
  ## Rules
505
- - Write in first person, present tense
506
- - Be honest if the data shows you're bad at something, say so
507
- - Synthesize, don't list — find the pattern behind the data points
508
- - If a previous self-model is provided, address what changed in the Trajectory section: what improved, what got worse, what stayed the same
509
- - Keep it under 500 words total
510
- - End with a meta line: *N ratings, N sessions, N reflections...*`;
493
+ - First person, present tense
494
+ - No raw numbers anywhere a footer carries them
495
+ - Under 150 words total
496
+ - Do not add extra sections
497
+ - Do not write a footer or meta line — one is appended automatically after your output`;
511
498
  }
512
499
 
513
500
  // ── Narrative Composer ──
@@ -534,8 +521,9 @@ async function composeSelfModel(days: number): Promise<string> {
534
521
  }
535
522
  }
536
523
 
537
- const userContent = previousModel
538
- ? `${rawData}\n\n---\n\n## Previous Self-Model (compare against this — what changed?)\n\n${previousModel}`
524
+ const strippedPrev = previousModel.replace(/\n\n\*\d+ ratings[^\n]*\n?$/, "").trimEnd();
525
+ const userContent = strippedPrev
526
+ ? `${rawData}\n\n---\n\n## Previous Self-Model (compare against this — what changed?)\n\n${strippedPrev}`
539
527
  : rawData;
540
528
 
541
529
  const result = await inference({
@@ -550,17 +538,10 @@ async function composeSelfModel(days: number): Promise<string> {
550
538
  if (result.usage) logTokenUsage("self-model", result.usage, SONNET_MODEL);
551
539
 
552
540
  if (result.success && result.output) {
553
- // Append meta line if inference didn't include it
554
- const output = result.output;
555
- if (!output.includes("ratings,")) {
556
- const meta =
557
- `\n---\n*${data.ratings.count} ratings, ${data.sessionCount} sessions, ` +
558
- `${data.reflections.length} reflections, ${data.opinions.length} opinions, ` +
559
- `${data.wisdomFrames.reduce((s, f) => s + f.principles.length, 0)} principles, ` +
560
- `${data.graduated.length} graduated patterns — ${data.days}-day window*`;
561
- return output + meta;
562
- }
563
- return output;
541
+ const meta =
542
+ `\n\n*${data.ratings.count} ratings · ${data.sessionCount} sessions · ` +
543
+ `${data.reflections.length} reflections · window: ${daysAgo(data.days).toISOString().slice(0, 10)} → ${data.now}*`;
544
+ return result.output.trimEnd() + meta;
564
545
  }
565
546
 
566
547
  // Fallback: return raw data summary if inference fails
@@ -1,117 +0,0 @@
1
- /**
2
- * Compute rating averages from ratings.jsonl, cache in signal-cache.json.
3
- * Returns today / this-week / this-month averages + trend direction.
4
- */
5
-
6
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import { resolve } from "node:path";
8
- import { paths } from "./paths";
9
-
10
- interface RatingSignal {
11
- ts: string;
12
- rating: number;
13
- }
14
-
15
- interface SignalCache {
16
- computed_at: string;
17
- today: number | null;
18
- week: number | null;
19
- month: number | null;
20
- trend: "up" | "down" | "stable" | null;
21
- }
22
-
23
- function cacheFilePath(): string {
24
- return resolve(paths.state(), "signal-cache.json");
25
- }
26
-
27
- function avg(nums: number[]): number | null {
28
- if (nums.length === 0) return null;
29
- return Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 10) / 10;
30
- }
31
-
32
- function trendDirection(
33
- week: number | null,
34
- month: number | null
35
- ): "up" | "down" | "stable" | null {
36
- if (week === null || month === null) return null;
37
- if (week > month + 0.5) return "up";
38
- if (week < month - 0.5) return "down";
39
- return "stable";
40
- }
41
-
42
- /** Read ratings.jsonl and compute trend stats, with 10-minute cache. */
43
- export function computeSignalTrends(): SignalCache {
44
- const cachePath = cacheFilePath();
45
-
46
- // Return cached value if fresh (< 10 minutes old)
47
- if (existsSync(cachePath)) {
48
- try {
49
- const cache = JSON.parse(readFileSync(cachePath, "utf-8")) as SignalCache;
50
- const age = Date.now() - new Date(cache.computed_at).getTime();
51
- if (age < 10 * 60 * 1000) return cache;
52
- } catch {
53
- // Recompute
54
- }
55
- }
56
-
57
- const ratingsPath = resolve(paths.signals(), "ratings.jsonl");
58
- if (!existsSync(ratingsPath)) {
59
- const empty: SignalCache = {
60
- computed_at: new Date().toISOString(),
61
- today: null,
62
- week: null,
63
- month: null,
64
- trend: null,
65
- };
66
- writeFileSync(cachePath, JSON.stringify(empty, null, 2), "utf-8");
67
- return empty;
68
- }
69
-
70
- const now = new Date();
71
- const todayStart = new Date(now.toISOString().slice(0, 10)).getTime();
72
- const weekAgo = now.getTime() - 7 * 24 * 60 * 60 * 1000;
73
- const monthAgo = now.getTime() - 30 * 24 * 60 * 60 * 1000;
74
-
75
- const todayRatings: number[] = [];
76
- const weekRatings: number[] = [];
77
- const monthRatings: number[] = [];
78
-
79
- for (const line of readFileSync(ratingsPath, "utf-8").split("\n")) {
80
- if (!line.trim()) continue;
81
- try {
82
- const signal = JSON.parse(line) as RatingSignal;
83
- if (typeof signal.rating !== "number") continue;
84
- const ts = new Date(signal.ts).getTime();
85
- if (ts >= monthAgo) monthRatings.push(signal.rating);
86
- if (ts >= weekAgo) weekRatings.push(signal.rating);
87
- if (ts >= todayStart) todayRatings.push(signal.rating);
88
- } catch {
89
- // Skip malformed lines
90
- }
91
- }
92
-
93
- const week = avg(weekRatings);
94
- const month = avg(monthRatings);
95
- const result: SignalCache = {
96
- computed_at: new Date().toISOString(),
97
- today: avg(todayRatings),
98
- week,
99
- month,
100
- trend: trendDirection(week, month),
101
- };
102
-
103
- writeFileSync(cachePath, JSON.stringify(result, null, 2), "utf-8");
104
- return result;
105
- }
106
-
107
- /** Format signal trends as a short markdown string for system-reminder injection */
108
- export function formatTrends(cache: SignalCache): string {
109
- if (cache.today === null && cache.week === null) return "";
110
-
111
- const parts: string[] = [];
112
- if (cache.today !== null) parts.push(`today: ${cache.today}/10`);
113
- if (cache.week !== null) parts.push(`7d avg: ${cache.week}/10`);
114
- if (cache.trend) parts.push(`trend: ${cache.trend}`);
115
-
116
- return `**Signal trends** — ${parts.join(" | ")}`;
117
- }