portable-agent-layer 0.78.1 → 0.78.3
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 +3 -2
- package/assets/skills/pal-reflect/SKILL.md +2 -2
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +4 -4
- package/package.json +2 -2
- package/src/cli/builtin-tools.ts +1 -0
- package/src/cli/index.ts +3 -48
- package/src/cli/setup-auto-update.ts +7 -7
- package/src/hooks/AutoUpdate.ts +1 -1
- package/src/hooks/handlers/persist-last-exchange.ts +39 -24
- package/src/hooks/handlers/session-intelligence.ts +6 -2
- package/src/hooks/lib/algorithm-review.ts +12 -7
- package/src/hooks/lib/auto-update.ts +16 -6
- package/src/hooks/lib/context.ts +7 -1
- package/src/hooks/lib/inference.ts +12 -45
- package/src/hooks/lib/log.ts +44 -3
- package/src/hooks/lib/paths.ts +7 -2
- package/src/hooks/lib/relationship.ts +2 -2
- package/src/hooks/lib/retrieval-index.ts +13 -6
- package/src/hooks/lib/security.ts +117 -73
- package/src/hooks/lib/semi-static.ts +10 -3
- package/src/hooks/lib/settings.ts +11 -12
- package/src/tools/{relationship-reflect.ts → agent/relationship-reflect.ts} +13 -12
- package/src/tools/control-room/ui/dist/assets/{index-DyhnNmdi.js → index-D_o9Usd_.js} +1 -1
- package/src/tools/control-room/ui/dist/index.html +1 -1
- package/src/tools/control-room/ui/screens/settings.tsx +2 -2
- package/src/tools/self-model.ts +1 -0
package/README.md
CHANGED
|
@@ -77,7 +77,7 @@ pal cli status # check your setup
|
|
|
77
77
|
| `pal cli init` | Scaffold PAL home directory and install hooks |
|
|
78
78
|
| `pal cli install` | Register hooks/skills for targets |
|
|
79
79
|
| `pal cli uninstall` | Remove hooks/skills for targets |
|
|
80
|
-
| `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this
|
|
80
|
+
| `pal cli update` | Update PAL (git pull or npm update) and reinstall hooks. Install asks once whether to do this on its own: when a session closes PAL checks for a release (at most hourly) and installs it, so opening PAL is never slowed. A failed update waits a day before retrying. The switch lives in the control room under Settings → Updates. On a repo install an update waits while the clone has uncommitted changes |
|
|
81
81
|
| `pal cli export` | Export user state (telos, memory) to a zip |
|
|
82
82
|
| `pal cli import` | Import user state from a zip |
|
|
83
83
|
| `pal cli status` | Show current PAL configuration |
|
|
@@ -156,7 +156,7 @@ PAL routes inference through the host agent's subscription CLI by default. API k
|
|
|
156
156
|
Enable verbose hook logging with:
|
|
157
157
|
|
|
158
158
|
```
|
|
159
|
-
pal cli debug on # enable → logs to
|
|
159
|
+
pal cli debug on # enable → logs to debug/debug.log
|
|
160
160
|
pal cli debug off # disable
|
|
161
161
|
pal cli debug # show current status and log path
|
|
162
162
|
```
|
|
@@ -165,6 +165,7 @@ pal cli debug # show current status and log path
|
|
|
165
165
|
|----------|-------------|
|
|
166
166
|
| `PAL_INFERENCE_DISABLED` | Set to `1` to disable all inference (used by the test suite to prevent real CLI spawns) |
|
|
167
167
|
| `PAL_NOTIFICATIONS_DISABLED` | Set to `1` to suppress desktop notifications (used by the test suite) |
|
|
168
|
+
| `PAL_TEST_SANDBOX` | Set by the test suite. Without a `PAL_HOME`, state goes to a temp directory instead of `~/.pal`, and installers refuse to touch real agent directories |
|
|
168
169
|
|
|
169
170
|
---
|
|
170
171
|
|
|
@@ -19,10 +19,10 @@ When `/pal-reflect` is invoked (by you in response to a nudge, or by the user di
|
|
|
19
19
|
## 1. Run the reflect tool
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
|
-
|
|
22
|
+
pal cli relationship-reflect
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
If the user passed `--dry-run`, append it: `
|
|
25
|
+
If the user passed `--dry-run`, append it: `pal cli relationship-reflect --dry-run`
|
|
26
26
|
|
|
27
27
|
## 2. Parse and present the output
|
|
28
28
|
|
|
@@ -383,7 +383,7 @@ Relationship notes (O/B types)
|
|
|
383
383
|
- Content is global/user-level — safe to pre-compile (not project-scoped)
|
|
384
384
|
|
|
385
385
|
**Tier 3 — Dynamic** (injected fresh each session by LoadContext hook):
|
|
386
|
-
- Handoff notes, session intelligence,
|
|
386
|
+
- Handoff notes, session intelligence, relationship notes, project history
|
|
387
387
|
- Changes per-session or is project-scoped — can't be pre-compiled
|
|
388
388
|
- Injected as `<system-reminder>` block via stdout
|
|
389
389
|
- Each section independently toggleable in `pal-settings.json → dynamicContext`
|
|
@@ -402,12 +402,12 @@ LoadContext.ts
|
|
|
402
402
|
└─► Build system-reminder (dynamic sections only):
|
|
403
403
|
1. loadAtStartup files (user-configured)
|
|
404
404
|
2. Handoff note (in-progress work from last session)
|
|
405
|
-
3.
|
|
406
|
-
4.
|
|
405
|
+
3. Due nudges (algorithm review, relationship reflect, learning analysis)
|
|
406
|
+
4. Session intelligence (rating trend, algorithm performance)
|
|
407
407
|
5. Recent interaction notes (last 2 days)
|
|
408
408
|
6. Active projects
|
|
409
409
|
7. Project session history (this project)
|
|
410
|
-
8.
|
|
410
|
+
8. Other recent learnings (other projects, titles only)
|
|
411
411
|
```
|
|
412
412
|
|
|
413
413
|
### On-Demand Context
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.78.
|
|
3
|
+
"version": "0.78.3",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"tool:synthesize": "bun run src/tools/agent/synthesize.ts",
|
|
65
65
|
"tool:thread": "bun run src/tools/agent/thread.ts",
|
|
66
66
|
"tool:wisdom-frame": "bun run src/tools/agent/wisdom-frame.ts",
|
|
67
|
-
"tool:reflect": "bun run src/tools/relationship-reflect.ts",
|
|
67
|
+
"tool:reflect": "bun run src/tools/agent/relationship-reflect.ts",
|
|
68
68
|
"eval:sentiment": "bun eval/run.ts sentiment",
|
|
69
69
|
"eval:failure-principle": "bun eval/run.ts failure-principle",
|
|
70
70
|
"eval:goal-links": "bun eval/run.ts goal-links",
|
package/src/cli/builtin-tools.ts
CHANGED
|
@@ -13,6 +13,7 @@ const BUILTIN_TOOLS = {
|
|
|
13
13
|
"handoff-note": () => import("../tools/agent/handoff-note"),
|
|
14
14
|
project: () => import("../tools/agent/project"),
|
|
15
15
|
"relationship-note": () => import("../tools/agent/relationship-note"),
|
|
16
|
+
"relationship-reflect": () => import("../tools/agent/relationship-reflect"),
|
|
16
17
|
synthesize: () => import("../tools/agent/synthesize"),
|
|
17
18
|
thread: () => import("../tools/agent/thread"),
|
|
18
19
|
"wisdom-frame": () => import("../tools/agent/wisdom-frame"),
|
package/src/cli/index.ts
CHANGED
|
@@ -47,7 +47,7 @@ import {
|
|
|
47
47
|
summarize,
|
|
48
48
|
} from "../hooks/lib/import-merge";
|
|
49
49
|
import { inference, previewInferenceRoute } from "../hooks/lib/inference";
|
|
50
|
-
import {
|
|
50
|
+
import { logDebug, recentHookErrors } from "../hooks/lib/log";
|
|
51
51
|
import { ensureRegistered, writeRegistryEntry } from "../hooks/lib/machine";
|
|
52
52
|
import { palHome, palPkg, paths, platform, toPath } from "../hooks/lib/paths";
|
|
53
53
|
import { auditBindings, describeBindingIssue } from "../hooks/lib/projects";
|
|
@@ -462,11 +462,6 @@ function resolveTargets(args: string[], health?: DoctorResult): Targets {
|
|
|
462
462
|
|
|
463
463
|
// ── Hook health ──
|
|
464
464
|
|
|
465
|
-
interface HookHealth {
|
|
466
|
-
totalErrors: number;
|
|
467
|
-
lastError: string | null;
|
|
468
|
-
}
|
|
469
|
-
|
|
470
465
|
function checkClaudeHooksRegistered(): boolean {
|
|
471
466
|
const settingsPath = resolve(platform.claudeDir(), "settings.json");
|
|
472
467
|
if (!existsSync(settingsPath)) return false;
|
|
@@ -645,7 +640,7 @@ async function probeInference(): Promise<void> {
|
|
|
645
640
|
);
|
|
646
641
|
} else {
|
|
647
642
|
console.log(
|
|
648
|
-
` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/
|
|
643
|
+
` ${red}✗${reset} ${tag} ${String(elapsedMs).padStart(6)}ms ${dim}failed — see ~/.pal/debug/debug.log${reset}`
|
|
649
644
|
);
|
|
650
645
|
}
|
|
651
646
|
}
|
|
@@ -733,46 +728,6 @@ function rtkInstallHint(): string {
|
|
|
733
728
|
return "`curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh`";
|
|
734
729
|
}
|
|
735
730
|
|
|
736
|
-
function checkHookHealth(home: string): HookHealth {
|
|
737
|
-
const stateDir = resolve(home, "memory", "state");
|
|
738
|
-
// Read current + all rotated logs (`.1`..`.5`) + legacy `.prev` so a recent
|
|
739
|
-
// rotation doesn't make the 24h window appear empty.
|
|
740
|
-
const candidates = [
|
|
741
|
-
resolve(stateDir, "debug.log"),
|
|
742
|
-
resolve(stateDir, "debug.log.prev"),
|
|
743
|
-
...Array.from({ length: DEBUG_LOG_MAX_ROTATED }, (_, i) =>
|
|
744
|
-
resolve(stateDir, `debug.log.${i + 1}`)
|
|
745
|
-
),
|
|
746
|
-
];
|
|
747
|
-
|
|
748
|
-
try {
|
|
749
|
-
let content = "";
|
|
750
|
-
for (const path of candidates) {
|
|
751
|
-
if (existsSync(path)) content += `${readFileSync(path, "utf-8")}\n`;
|
|
752
|
-
}
|
|
753
|
-
if (!content) return { totalErrors: 0, lastError: null };
|
|
754
|
-
|
|
755
|
-
const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
|
|
756
|
-
|
|
757
|
-
// Filter to last 24h
|
|
758
|
-
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
759
|
-
const recentErrors = lines.filter((line) => {
|
|
760
|
-
const match = new RegExp(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/).exec(line);
|
|
761
|
-
if (!match) return false;
|
|
762
|
-
return new Date(match[1]) > cutoff;
|
|
763
|
-
});
|
|
764
|
-
|
|
765
|
-
const lastError =
|
|
766
|
-
recentErrors.length > 0
|
|
767
|
-
? (recentErrors.at(-1) ?? "").replace(/^\[.*?\] ERROR /, "").slice(0, 120)
|
|
768
|
-
: null;
|
|
769
|
-
|
|
770
|
-
return { totalErrors: recentErrors.length, lastError };
|
|
771
|
-
} catch {
|
|
772
|
-
return { totalErrors: 0, lastError: null };
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
731
|
// ── Doctor ──
|
|
777
732
|
|
|
778
733
|
interface DoctorResult {
|
|
@@ -1131,7 +1086,7 @@ function doctor(silent = false): DoctorResult {
|
|
|
1131
1086
|
: warn("PAL_PERPLEXITY_API_KEY — not set (optional, for Perplexity researcher)");
|
|
1132
1087
|
|
|
1133
1088
|
// Hook health from debug.log
|
|
1134
|
-
const hookHealth =
|
|
1089
|
+
const hookHealth = recentHookErrors();
|
|
1135
1090
|
if (hookHealth.totalErrors === 0) {
|
|
1136
1091
|
ok("Hooks: no recent errors");
|
|
1137
1092
|
} else {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Unattended updates — one-time opt-in prompt.
|
|
3
3
|
*
|
|
4
4
|
* Asked once during `pal install`, the same seam git attribution uses, so a new
|
|
5
5
|
* user sees it at init and an existing one on their next update. The non-TTY
|
|
@@ -14,11 +14,11 @@ import { raw as readSettings, write as writeSettings } from "../hooks/lib/settin
|
|
|
14
14
|
|
|
15
15
|
/** Only a git clone can be mid-change; a global package install has no such state. */
|
|
16
16
|
function whatItDoes(): string {
|
|
17
|
-
const
|
|
18
|
-
"
|
|
17
|
+
const onClose =
|
|
18
|
+
"When you close a session, PAL checks for a new release (at most hourly) and installs it in the background.\nOpening PAL is never slowed down, and the statusline says when to restart.";
|
|
19
19
|
return isRepoMode()
|
|
20
|
-
? `${
|
|
21
|
-
:
|
|
20
|
+
? `${onClose}\nIt waits while this clone has uncommitted changes.`
|
|
21
|
+
: onClose;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
export async function promptAutoUpdate(): Promise<void> {
|
|
@@ -31,7 +31,7 @@ export async function promptAutoUpdate(): Promise<void> {
|
|
|
31
31
|
clack.note(whatItDoes(), "Keep PAL up to date on its own?");
|
|
32
32
|
|
|
33
33
|
const enabled = await clack.confirm({
|
|
34
|
-
message: "Turn on
|
|
34
|
+
message: "Turn on automatic updates?",
|
|
35
35
|
initialValue: false,
|
|
36
36
|
});
|
|
37
37
|
if (clack.isCancel(enabled)) {
|
|
@@ -41,6 +41,6 @@ export async function promptAutoUpdate(): Promise<void> {
|
|
|
41
41
|
|
|
42
42
|
settings.autoUpdate = { enabled: enabled === true, decided: true };
|
|
43
43
|
writeSettings(settings);
|
|
44
|
-
const state = enabled ? "
|
|
44
|
+
const state = enabled ? "Automatic updates on" : "Automatic updates off";
|
|
45
45
|
clack.outro(`${state} ✓ · change later: control room → Settings → Updates`);
|
|
46
46
|
}
|
package/src/hooks/AutoUpdate.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Spawned, never awaited — runs the
|
|
2
|
+
* Spawned, never awaited — runs the unattended self-update in a process of its own.
|
|
3
3
|
*
|
|
4
4
|
* LoadContext decides whether today's update is due; the control room's button
|
|
5
5
|
* starts this directly. Either way the work happens here so nothing waits on a
|
|
@@ -35,6 +35,34 @@ function isProtectedHandoff(entry: unknown): boolean {
|
|
|
35
35
|
return Date.now() - new Date(e.timestamp).getTime() <= HANDOFF_STALE_MS;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
interface AutoHandoff {
|
|
39
|
+
title: string;
|
|
40
|
+
status: string;
|
|
41
|
+
handoff: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readHandoffs(handoffPath: string): Record<string, unknown> {
|
|
45
|
+
try {
|
|
46
|
+
return existsSync(handoffPath) ? JSON.parse(readFileSync(handoffPath, "utf-8")) : {};
|
|
47
|
+
} catch {
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Record this cwd's handoff unless a fresh deliberate note already owns it (ISC-39). */
|
|
53
|
+
export function writeAutoHandoff(cwd: string, entry: AutoHandoff): void {
|
|
54
|
+
const handoffPath = resolve(ensureDir(paths.state()), "last-handoff.json");
|
|
55
|
+
const existing = readHandoffs(handoffPath);
|
|
56
|
+
if (isProtectedHandoff(existing[cwd])) return;
|
|
57
|
+
existing[cwd] = {
|
|
58
|
+
timestamp: new Date().toISOString(),
|
|
59
|
+
...entry,
|
|
60
|
+
artifacts: [],
|
|
61
|
+
source: "auto",
|
|
62
|
+
};
|
|
63
|
+
writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
64
|
+
}
|
|
65
|
+
|
|
38
66
|
export function persistLastExchange(
|
|
39
67
|
messages: ParsedMessage[],
|
|
40
68
|
sessionId: string,
|
|
@@ -59,30 +87,17 @@ export function persistLastExchange(
|
|
|
59
87
|
writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
|
|
60
88
|
writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
|
|
61
89
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
]
|
|
74
|
-
.filter(Boolean)
|
|
75
|
-
.join("");
|
|
76
|
-
existing[cwd] = {
|
|
77
|
-
timestamp: new Date().toISOString(),
|
|
78
|
-
title,
|
|
79
|
-
status: detectStatus(lastAssistant),
|
|
80
|
-
handoff,
|
|
81
|
-
artifacts: [],
|
|
82
|
-
source: "auto",
|
|
83
|
-
};
|
|
84
|
-
writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
85
|
-
}
|
|
90
|
+
const handoff = [
|
|
91
|
+
lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
|
|
92
|
+
lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
|
|
93
|
+
]
|
|
94
|
+
.filter(Boolean)
|
|
95
|
+
.join("");
|
|
96
|
+
writeAutoHandoff(cwd, {
|
|
97
|
+
title: (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim(),
|
|
98
|
+
status: detectStatus(lastAssistant),
|
|
99
|
+
handoff,
|
|
100
|
+
});
|
|
86
101
|
|
|
87
102
|
logDebug(
|
|
88
103
|
"persist-last-exchange",
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stop handler: unified session intelligence capture.
|
|
3
3
|
*
|
|
4
|
-
* Produces: title, summary, insights via Haiku.
|
|
5
|
-
* Writes: session learning file, project history.
|
|
4
|
+
* Produces: title, summary, insights, handoff via Haiku.
|
|
5
|
+
* Writes: session learning file, project history, auto handoff (in-progress only).
|
|
6
6
|
*
|
|
7
7
|
* Relationship notes → written in ALGORITHM LEARN phase via relationship-note.ts
|
|
8
8
|
* Handoff notes → written in ALGORITHM LEARN phase via handoff-note.ts
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
parseMessages,
|
|
33
33
|
} from "../lib/transcript";
|
|
34
34
|
import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
|
|
35
|
+
import { writeAutoHandoff } from "./persist-last-exchange";
|
|
35
36
|
|
|
36
37
|
// ── JSON schema for merged Haiku call ──
|
|
37
38
|
|
|
@@ -140,6 +141,9 @@ export async function captureSessionIntelligence(
|
|
|
140
141
|
const title = output?.title || extractContent(lastUser).slice(0, 80) || "session";
|
|
141
142
|
const summary = output?.summary || lastAssistantText.slice(0, 600);
|
|
142
143
|
const insights = output?.insights || "";
|
|
144
|
+
if (status === "in-progress" && output?.handoff) {
|
|
145
|
+
writeAutoHandoff(process.cwd(), { title, status, handoff: output.handoff });
|
|
146
|
+
}
|
|
143
147
|
// ── Write session learning file ──
|
|
144
148
|
|
|
145
149
|
const category = categorizeLearning(title, summary);
|
|
@@ -35,11 +35,13 @@ export function writeReviewMark(ts: string): void {
|
|
|
35
35
|
writeFileSync(markFile(), `${JSON.stringify({ lastReviewedTs: ts }, null, 2)}\n`);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
function hasAlgorithmUpdateSkill(root: string): boolean {
|
|
39
|
+
return existsSync(resolve(root, ".agents", "skills", "algorithm-update", "SKILL.md"));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** True when PAL runs from its checkout, or a session is working inside one. */
|
|
43
|
+
export function isMaintainerEnv(cwd: string = process.cwd()): boolean {
|
|
44
|
+
return hasAlgorithmUpdateSkill(palPkg()) || hasAlgorithmUpdateSkill(cwd);
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/** Count reflections newer than the last review mark (all of them, if never reviewed). */
|
|
@@ -60,8 +62,11 @@ export interface ReviewNudge {
|
|
|
60
62
|
* Nudge data when BOTH thresholds are met (≥25 new AND ≥7 days) in a maintainer
|
|
61
63
|
* env, else null. `now` is injected for testability.
|
|
62
64
|
*/
|
|
63
|
-
export function algorithmReviewNudge(
|
|
64
|
-
|
|
65
|
+
export function algorithmReviewNudge(
|
|
66
|
+
now: Date,
|
|
67
|
+
cwd: string = process.cwd()
|
|
68
|
+
): ReviewNudge | null {
|
|
69
|
+
if (!isMaintainerEnv(cwd)) return null;
|
|
65
70
|
const mark = readReviewMark();
|
|
66
71
|
const count = countUnreviewed(mark);
|
|
67
72
|
if (count < NUDGE_MIN_COUNT) return null;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The unattended self-update: when it may run, and what came of it.
|
|
3
3
|
*
|
|
4
4
|
* `pal cli update` still owns the pull and the reinstall — this module only
|
|
5
5
|
* decides whether to start it and records the outcome, so the command shape
|
|
@@ -41,7 +41,8 @@ export interface AutoUpdateStatus {
|
|
|
41
41
|
last: AutoUpdateLedger | null;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
const
|
|
44
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
45
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
45
46
|
const RESCUE_AFTER_MS = 3 * DAY_MS;
|
|
46
47
|
const LOCK_STALE_MS = 30 * 60 * 1000;
|
|
47
48
|
const DIRTY_TREE = "uncommitted changes in the PAL repo";
|
|
@@ -122,15 +123,24 @@ function recordSkip(reason: string): void {
|
|
|
122
123
|
logDebug("auto-update", `skipped: ${reason}`);
|
|
123
124
|
}
|
|
124
125
|
|
|
125
|
-
|
|
126
|
+
/**
|
|
127
|
+
* A clean run — including one that found nothing to install — only waits out the
|
|
128
|
+
* hour, so a release published after the morning's check still lands today. A
|
|
129
|
+
* failed or interrupted run waits a day, so a broken update cannot loop.
|
|
130
|
+
*/
|
|
131
|
+
function waitAfter(ledger: AutoUpdateLedger): number {
|
|
132
|
+
return ledger.ok === true ? HOUR_MS : DAY_MS;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function heldByLastRun(ledger: AutoUpdateLedger | null, now: number): boolean {
|
|
126
136
|
if (!ledger?.attemptedAt) return false;
|
|
127
|
-
return now - new Date(ledger.attemptedAt).getTime() <
|
|
137
|
+
return now - new Date(ledger.attemptedAt).getTime() < waitAfter(ledger);
|
|
128
138
|
}
|
|
129
139
|
|
|
130
|
-
/** The unattended gate: opted in, not
|
|
140
|
+
/** The unattended gate: opted in, not held by the last run, and safe to pull. */
|
|
131
141
|
export function shouldAutoUpdate(now: number = Date.now()): boolean {
|
|
132
142
|
if (!isAutoUpdateEnabled()) return false;
|
|
133
|
-
if (
|
|
143
|
+
if (heldByLastRun(readLedger(), now)) return false;
|
|
134
144
|
if (hasUncommittedChanges()) {
|
|
135
145
|
recordSkip(DIRTY_TREE);
|
|
136
146
|
return false;
|
package/src/hooks/lib/context.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { resolve } from "node:path";
|
|
|
8
8
|
import { loadReflectNudge } from "../handlers/reflect-trigger";
|
|
9
9
|
import { loadAlgorithmReviewNudge } from "./algorithm-review";
|
|
10
10
|
import { loadAnalyzeNudge } from "./analyze-nudge";
|
|
11
|
+
import { resolveAnchor } from "./anchor";
|
|
11
12
|
import { readLearnings } from "./learning-store";
|
|
12
13
|
import { loadOpinionContext } from "./opinions";
|
|
13
14
|
import { paths, toPath } from "./paths";
|
|
@@ -105,6 +106,11 @@ function loadProjectHistoryContext(): string {
|
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
109
|
+
function stampedPath(stamp: string): string {
|
|
110
|
+
const resolved = resolveAnchor(stamp);
|
|
111
|
+
return resolved.state === "unresolvable" ? stamp : resolved.path;
|
|
112
|
+
}
|
|
113
|
+
|
|
108
114
|
/**
|
|
109
115
|
* Filter raw relationship note lines:
|
|
110
116
|
* - O entries: stripped (loaded natively via digest)
|
|
@@ -125,7 +131,7 @@ function filterRelationshipNotes(notes: string, cwd: string): string {
|
|
|
125
131
|
}
|
|
126
132
|
const cwdMatch = new RegExp(/<!--.*cwd:(\S+)/).exec(line);
|
|
127
133
|
if (cwdMatch) {
|
|
128
|
-
blockCwd = cwdMatch[1];
|
|
134
|
+
blockCwd = stampedPath(cwdMatch[1]);
|
|
129
135
|
continue;
|
|
130
136
|
}
|
|
131
137
|
if (/^\s*<!--/.test(line)) continue;
|
|
@@ -214,40 +214,32 @@ export async function inference(opts: InferenceOptions): Promise<InferenceResult
|
|
|
214
214
|
// Per-agent CLI metadata — binary presence + argv builders
|
|
215
215
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
const binaryCache = new Map<string, string | null>();
|
|
218
|
+
|
|
219
|
+
function cachedBinary(name: string): string | null {
|
|
220
|
+
const key = `${name}\0${process.env.PATH ?? ""}`;
|
|
221
|
+
if (!binaryCache.has(key)) binaryCache.set(key, findBinaryOnPath(name));
|
|
222
|
+
return binaryCache.get(key) ?? null;
|
|
223
|
+
}
|
|
222
224
|
|
|
223
225
|
function getClaudeBinary(): string | null {
|
|
224
|
-
|
|
225
|
-
claudeBinaryCache = findBinaryOnPath("claude");
|
|
226
|
-
return claudeBinaryCache;
|
|
226
|
+
return cachedBinary("claude");
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
function getCodexBinary(): string | null {
|
|
230
|
-
|
|
231
|
-
codexBinaryCache = findBinaryOnPath("codex");
|
|
232
|
-
return codexBinaryCache;
|
|
230
|
+
return cachedBinary("codex");
|
|
233
231
|
}
|
|
234
232
|
|
|
235
233
|
function getOpencodeBinary(): string | null {
|
|
236
|
-
|
|
237
|
-
opencodeBinaryCache = findBinaryOnPath("opencode");
|
|
238
|
-
return opencodeBinaryCache;
|
|
234
|
+
return cachedBinary("opencode");
|
|
239
235
|
}
|
|
240
236
|
|
|
241
237
|
function getCopilotBinary(): string | null {
|
|
242
|
-
|
|
243
|
-
copilotBinaryCache = findBinaryOnPath("copilot");
|
|
244
|
-
return copilotBinaryCache;
|
|
238
|
+
return cachedBinary("copilot");
|
|
245
239
|
}
|
|
246
240
|
|
|
247
241
|
function getCursorBinary(): string | null {
|
|
248
|
-
|
|
249
|
-
cursorBinaryCache = findBinaryOnPath("cursor-agent");
|
|
250
|
-
return cursorBinaryCache;
|
|
242
|
+
return cachedBinary("cursor-agent");
|
|
251
243
|
}
|
|
252
244
|
|
|
253
245
|
function hasClaudeBinary(): boolean {
|
|
@@ -266,31 +258,6 @@ function hasCursorBinary(): boolean {
|
|
|
266
258
|
return getCursorBinary() !== null;
|
|
267
259
|
}
|
|
268
260
|
|
|
269
|
-
/** Test-only: reset the cached claude-binary resolution. */
|
|
270
|
-
export function _resetClaudeBinaryCache(): void {
|
|
271
|
-
claudeBinaryCache = undefined;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Test-only: reset the cached codex-binary resolution. */
|
|
275
|
-
export function _resetCodexBinaryCache(): void {
|
|
276
|
-
codexBinaryCache = undefined;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** Test-only: reset the cached opencode-binary resolution. */
|
|
280
|
-
export function _resetOpencodeBinaryCache(): void {
|
|
281
|
-
opencodeBinaryCache = undefined;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/** Test-only: reset the cached copilot-binary resolution. */
|
|
285
|
-
export function _resetCopilotBinaryCache(): void {
|
|
286
|
-
copilotBinaryCache = undefined;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
/** Test-only: reset the cached cursor-binary resolution. */
|
|
290
|
-
export function _resetCursorBinaryCache(): void {
|
|
291
|
-
cursorBinaryCache = undefined;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
261
|
/**
|
|
295
262
|
* Build the argv for `claude --print …`. Pure.
|
|
296
263
|
*
|
package/src/hooks/lib/log.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Simple file-based debug logger for PAL hooks.
|
|
3
|
-
* Writes to
|
|
3
|
+
* Writes to debug/debug.log, rotated by size.
|
|
4
4
|
*
|
|
5
5
|
* Only writes when debug is enabled (`pal cli debug on`) or when called via logError (always logged).
|
|
6
6
|
*/
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
import {
|
|
9
9
|
appendFileSync,
|
|
10
10
|
existsSync,
|
|
11
|
+
readFileSync,
|
|
11
12
|
renameSync,
|
|
12
13
|
statSync,
|
|
13
14
|
unlinkSync,
|
|
@@ -78,8 +79,48 @@ function rotateIfNeeded(path: string): void {
|
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
|
|
81
|
-
|
|
82
|
-
|
|
82
|
+
export interface HookHealth {
|
|
83
|
+
totalErrors: number;
|
|
84
|
+
lastError: string | null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
88
|
+
|
|
89
|
+
function logFilesOldestFirst(): string[] {
|
|
90
|
+
const path = logFile();
|
|
91
|
+
const rotated = Array.from(
|
|
92
|
+
{ length: MAX_ROTATED },
|
|
93
|
+
(_, i) => `${path}.${MAX_ROTATED - i}`
|
|
94
|
+
);
|
|
95
|
+
return [...rotated, `${path}.prev`, path];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function readAllLogs(): string {
|
|
99
|
+
return logFilesOldestFirst()
|
|
100
|
+
.filter((path) => existsSync(path))
|
|
101
|
+
.map((path) => readFileSync(path, "utf-8"))
|
|
102
|
+
.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function loggedAt(line: string): number {
|
|
106
|
+
const match = /^\[(\d{4}-\d{2}-\d{2}) (\d{2}:\d{2}:\d{2})\]/.exec(line);
|
|
107
|
+
return match ? Date.parse(`${match[1]}T${match[2]}Z`) : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function recentHookErrors(now: number = Date.now()): HookHealth {
|
|
111
|
+
try {
|
|
112
|
+
const recent = readAllLogs()
|
|
113
|
+
.split("\n")
|
|
114
|
+
.filter((line) => line.includes("] ERROR ") && loggedAt(line) > now - DAY_MS);
|
|
115
|
+
const last = recent.at(-1);
|
|
116
|
+
return {
|
|
117
|
+
totalErrors: recent.length,
|
|
118
|
+
lastError: last ? last.replace(/^\[.*?\] ERROR /, "").slice(0, 120) : null,
|
|
119
|
+
};
|
|
120
|
+
} catch {
|
|
121
|
+
return { totalErrors: 0, lastError: null };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
83
124
|
|
|
84
125
|
function isDebugEnabled(): boolean {
|
|
85
126
|
return existsSync(resolve(palHome(), "memory", "state", "debug-enabled"));
|
package/src/hooks/lib/paths.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
-
import { homedir } from "node:os";
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
|
|
5
5
|
/**
|
|
@@ -56,8 +56,13 @@ export function palPkg(): string {
|
|
|
56
56
|
* Always resolves to ~/.pal/ regardless of where the package lives.
|
|
57
57
|
* Power users who want memory/telos versioned in a repo can override via PAL_HOME.
|
|
58
58
|
*/
|
|
59
|
+
function defaultPalHome(): string {
|
|
60
|
+
if (process.env.PAL_TEST_SANDBOX) return resolve(tmpdir(), "pal-test-home");
|
|
61
|
+
return resolve(homedir(), ".pal");
|
|
62
|
+
}
|
|
63
|
+
|
|
59
64
|
export function palHome(): string {
|
|
60
|
-
return envPath("PAL_HOME",
|
|
65
|
+
return envPath("PAL_HOME", defaultPalHome());
|
|
61
66
|
}
|
|
62
67
|
|
|
63
68
|
/** Ensure a directory exists, creating it recursively if needed */
|
|
@@ -76,8 +76,8 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): Appe
|
|
|
76
76
|
|
|
77
77
|
const timestamp = new Date().toTimeString().slice(0, 5);
|
|
78
78
|
lines.push(`## ${timestamp}`);
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
const session = sessionId ? `session:${sessionId} ` : "";
|
|
80
|
+
lines.push(`<!-- ${session}cwd:${encodeAnchor(process.cwd())} -->`);
|
|
81
81
|
|
|
82
82
|
for (const note of fresh) {
|
|
83
83
|
if (note.type === "O" && note.confidence !== undefined) {
|