portable-agent-layer 0.51.2 → 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.
@@ -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.2",
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": {
@@ -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
 
@@ -5,7 +5,14 @@
5
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
17
  import { palHome, paths } from "./paths";
11
18
 
@@ -14,7 +21,7 @@ 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 {
@@ -90,6 +97,31 @@ export function logDebug(source: string, message: string): void {
90
97
  }
91
98
  }
92
99
 
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
+
93
125
  /** Log an error (always written, regardless of debug mode) */
94
126
  export function logError(source: string, error: unknown): void {
95
127
  const path = logFile();
@@ -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 ?? [])];
@@ -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
- }