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.
- package/assets/statusline.ps1 +2 -2
- package/assets/statusline.sh +2 -2
- package/assets/templates/pal-settings.json +0 -3
- package/package.json +1 -1
- package/src/hooks/LoadContext.ts +2 -1
- package/src/hooks/UserPromptOrchestrator.ts +5 -3
- package/src/hooks/handlers/inject-retrieval.ts +5 -3
- package/src/hooks/handlers/rating.ts +1 -1
- package/src/hooks/lib/context.ts +1 -18
- package/src/hooks/lib/log.ts +34 -2
- package/src/hooks/lib/paths.ts +1 -0
- package/src/hooks/lib/projects.ts +0 -11
- package/src/hooks/lib/security.ts +1 -7
- package/src/hooks/lib/semi-static.ts +1 -62
- package/src/targets/lib.ts +41 -16
- package/src/targets/opencode/plugin.ts +7 -5
- package/src/tools/agent/synthesize.ts +40 -0
- package/src/tools/self-model.ts +18 -37
- package/src/hooks/lib/signal-trends.ts +0 -117
package/assets/statusline.ps1
CHANGED
|
@@ -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\
|
|
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
|
|
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
|
)
|
package/assets/statusline.sh
CHANGED
|
@@ -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/
|
|
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
|
|
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
|
)
|
package/package.json
CHANGED
package/src/hooks/LoadContext.ts
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
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"
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/hooks/lib/log.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
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();
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -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,
|
|
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,
|
package/src/targets/lib.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
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
|
|
215
|
+
// Collect canonical PAL hook commands from template (path-normalized)
|
|
199
216
|
if (template.hooks && result.hooks) {
|
|
200
|
-
const
|
|
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)
|
|
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 || !
|
|
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
|
-
/**
|
|
381
|
-
|
|
382
|
-
|
|
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) =>
|
|
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(
|
|
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(
|
|
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
|
|
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 (
|
|
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:
|
|
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);
|
package/src/tools/self-model.ts
CHANGED
|
@@ -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,
|
|
477
|
+
You will receive structured data about your performance, your user's preferences, and behavioral patterns over a time window.
|
|
478
478
|
|
|
479
|
-
|
|
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
|
|
487
|
-
One paragraph.
|
|
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
|
-
**##
|
|
490
|
-
|
|
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
|
-
-
|
|
506
|
-
-
|
|
507
|
-
-
|
|
508
|
-
-
|
|
509
|
-
-
|
|
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
|
|
538
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
}
|