portable-agent-layer 0.78.2 → 0.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/assets/schema/pal-settings.schema.json +0 -1
- package/assets/skills/pal-reflect/SKILL.md +2 -2
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +4 -4
- package/assets/templates/pal-settings.json +0 -1
- 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/LoadContext.ts +13 -1
- package/src/hooks/PreCompactPersist.ts +2 -2
- package/src/hooks/handlers/persist-last-exchange.ts +128 -33
- package/src/hooks/handlers/session-intelligence.ts +53 -14
- package/src/hooks/lib/algorithm-review.ts +12 -7
- package/src/hooks/lib/auto-update.ts +16 -6
- package/src/hooks/lib/context.ts +18 -56
- package/src/hooks/lib/graduation.ts +2 -2
- package/src/hooks/lib/handoff-context.ts +112 -0
- 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/projects.ts +16 -4
- package/src/hooks/lib/relationship.ts +2 -2
- package/src/hooks/lib/retrieval-index.ts +13 -6
- package/src/hooks/lib/security.ts +29 -3
- package/src/hooks/lib/semi-static.ts +10 -3
- package/src/hooks/lib/session-context.ts +22 -0
- package/src/hooks/lib/session-dir.ts +9 -0
- package/src/hooks/lib/settings.ts +11 -12
- package/src/hooks/lib/work-tracking.ts +9 -1
- 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
|
@@ -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,15 +8,17 @@ 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 {
|
|
11
|
+
import { resolveAnchor } from "./anchor";
|
|
12
|
+
import { loadHandoffContext } from "./handoff-context";
|
|
12
13
|
import { loadOpinionContext } from "./opinions";
|
|
13
14
|
import { paths, toPath } from "./paths";
|
|
14
15
|
import { loadActiveProjectsContext } from "./projects";
|
|
15
16
|
import { loadRecentNotes } from "./relationship";
|
|
16
17
|
import { loadFailurePatterns } from "./semi-static";
|
|
18
|
+
import { sessionDir } from "./session-dir";
|
|
17
19
|
import * as settings from "./settings";
|
|
18
20
|
import { readFramePrinciples } from "./wisdom";
|
|
19
|
-
import { readProjectHistory } from "./work-tracking";
|
|
21
|
+
import { lastSessionTitle, readProjectHistory } from "./work-tracking";
|
|
20
22
|
|
|
21
23
|
/** Load and concatenate loadAtStartup files */
|
|
22
24
|
function loadStartupFiles(): string {
|
|
@@ -50,29 +52,6 @@ export function loadWisdomContext(): string {
|
|
|
50
52
|
}
|
|
51
53
|
}
|
|
52
54
|
|
|
53
|
-
/** Load recent session learning files as digest, with detail for current project */
|
|
54
|
-
export function loadLearningDigest(): string {
|
|
55
|
-
try {
|
|
56
|
-
const cwd = process.cwd();
|
|
57
|
-
const entries = readLearnings(paths.sessionLearning(), 10);
|
|
58
|
-
if (entries.length === 0) return "";
|
|
59
|
-
|
|
60
|
-
// This-project learnings are now in loadProjectHistoryContext(); only show cross-project here
|
|
61
|
-
const other = entries.filter((e) => e.cwd !== cwd).slice(0, 5);
|
|
62
|
-
|
|
63
|
-
if (other.length === 0) return "";
|
|
64
|
-
|
|
65
|
-
const lines: string[] = [];
|
|
66
|
-
|
|
67
|
-
lines.push("## Other Recent Learnings");
|
|
68
|
-
for (const e of other) lines.push(`- ${e.title}`);
|
|
69
|
-
|
|
70
|
-
return lines.join("\n");
|
|
71
|
-
} catch {
|
|
72
|
-
return "";
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
55
|
/** Load self-model for session context injection */
|
|
77
56
|
function loadSelfModel(): string {
|
|
78
57
|
try {
|
|
@@ -105,6 +84,11 @@ function loadProjectHistoryContext(): string {
|
|
|
105
84
|
}
|
|
106
85
|
}
|
|
107
86
|
|
|
87
|
+
function stampedPath(stamp: string): string {
|
|
88
|
+
const resolved = resolveAnchor(stamp);
|
|
89
|
+
return resolved.state === "unresolvable" ? stamp : resolved.path;
|
|
90
|
+
}
|
|
91
|
+
|
|
108
92
|
/**
|
|
109
93
|
* Filter raw relationship note lines:
|
|
110
94
|
* - O entries: stripped (loaded natively via digest)
|
|
@@ -125,7 +109,7 @@ function filterRelationshipNotes(notes: string, cwd: string): string {
|
|
|
125
109
|
}
|
|
126
110
|
const cwdMatch = new RegExp(/<!--.*cwd:(\S+)/).exec(line);
|
|
127
111
|
if (cwdMatch) {
|
|
128
|
-
blockCwd = cwdMatch[1];
|
|
112
|
+
blockCwd = stampedPath(cwdMatch[1]);
|
|
129
113
|
continue;
|
|
130
114
|
}
|
|
131
115
|
if (/^\s*<!--/.test(line)) continue;
|
|
@@ -213,31 +197,6 @@ function loadSessionIntelligence(): string {
|
|
|
213
197
|
}
|
|
214
198
|
}
|
|
215
199
|
|
|
216
|
-
/** Load handoff state for the current project */
|
|
217
|
-
function loadHandoff(): string {
|
|
218
|
-
try {
|
|
219
|
-
const p = resolve(paths.state(), "last-handoff.json");
|
|
220
|
-
if (!existsSync(p)) return "";
|
|
221
|
-
const handoffs = JSON.parse(readFileSync(p, "utf-8"));
|
|
222
|
-
const cwd = process.cwd();
|
|
223
|
-
const entry = handoffs[cwd];
|
|
224
|
-
if (!entry?.handoff || entry.status !== "in-progress") return "";
|
|
225
|
-
|
|
226
|
-
const age = Date.now() - new Date(entry.timestamp).getTime();
|
|
227
|
-
if (age > 7 * 24 * 60 * 60 * 1000) return ""; // stale after 7 days
|
|
228
|
-
|
|
229
|
-
return [
|
|
230
|
-
"## Pick Up Where You Left Off",
|
|
231
|
-
`*Previous session: ${entry.title}*`,
|
|
232
|
-
"",
|
|
233
|
-
entry.handoff,
|
|
234
|
-
"→ Continue this work or explicitly close it before starting something new.",
|
|
235
|
-
].join("\n");
|
|
236
|
-
} catch {
|
|
237
|
-
return "";
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
200
|
/** Truncate text to maxChars at the last complete line boundary */
|
|
242
201
|
function capSection(text: string, maxChars: number): string {
|
|
243
202
|
if (text.length <= maxChars) return text;
|
|
@@ -266,7 +225,9 @@ export type AgentTarget = "claude" | "opencode" | "cursor" | "copilot";
|
|
|
266
225
|
* opts.agent — agent target; Claude Code skips semi-static sections (self-model,
|
|
267
226
|
* wisdom, opinions) that load natively via @imports in CLAUDE.md.
|
|
268
227
|
*/
|
|
269
|
-
export function buildSystemReminder(
|
|
228
|
+
export function buildSystemReminder(
|
|
229
|
+
opts: { agent?: AgentTarget; withoutHandoff?: boolean } = {}
|
|
230
|
+
): string {
|
|
270
231
|
// Semi-static sections loaded natively via @imports (Claude Code) or
|
|
271
232
|
// instructions[] (opencode). Skip them from hook output for those agents.
|
|
272
233
|
const skipSemiStatic =
|
|
@@ -281,12 +242,11 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
|
|
|
281
242
|
const relationship = settings.isEnabled("relationship")
|
|
282
243
|
? loadRelationshipContext()
|
|
283
244
|
: "";
|
|
284
|
-
const digest = settings.isEnabled("learningDigest") ? loadLearningDigest() : "";
|
|
285
245
|
const projectHistory = settings.isEnabled("projectHistory")
|
|
286
246
|
? loadProjectHistoryContext()
|
|
287
247
|
: "";
|
|
288
248
|
const activeProjects = settings.isEnabled("projects")
|
|
289
|
-
? loadActiveProjectsContext()
|
|
249
|
+
? loadActiveProjectsContext(process.cwd(), lastSessionTitle)
|
|
290
250
|
: "";
|
|
291
251
|
const failures =
|
|
292
252
|
settings.isEnabled("failurePatterns") && !skipSemiStatic ? loadFailurePatterns() : "";
|
|
@@ -297,7 +257,10 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
|
|
|
297
257
|
const intelligence = settings.isEnabled("sessionIntelligence")
|
|
298
258
|
? loadSessionIntelligence()
|
|
299
259
|
: "";
|
|
300
|
-
const handoff =
|
|
260
|
+
const handoff =
|
|
261
|
+
!opts.withoutHandoff && settings.isEnabled("handoff")
|
|
262
|
+
? loadHandoffContext(sessionDir())
|
|
263
|
+
: "";
|
|
301
264
|
// Maintainer-only: self-gates to a repo checkout, "" for everyone else.
|
|
302
265
|
const algoReview = loadAlgorithmReviewNudge();
|
|
303
266
|
const reflectNudge = loadReflectNudge();
|
|
@@ -315,7 +278,6 @@ export function buildSystemReminder(opts: { agent?: AgentTarget } = {}): string
|
|
|
315
278
|
if (relationship) parts.push(relationship);
|
|
316
279
|
if (activeProjects) parts.push(activeProjects);
|
|
317
280
|
if (projectHistory) parts.push(projectHistory);
|
|
318
|
-
if (digest) parts.push(digest);
|
|
319
281
|
if (failures) parts.push(failures);
|
|
320
282
|
if (parts.length === 0) return "";
|
|
321
283
|
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
14
14
|
import { resolve } from "node:path";
|
|
15
|
-
import {
|
|
15
|
+
import { canInfer } from "./inference";
|
|
16
16
|
import {
|
|
17
17
|
type FailureEntry,
|
|
18
18
|
type LearningEntry,
|
|
@@ -215,7 +215,7 @@ async function generateRecommendations(
|
|
|
215
215
|
ratings: RatingsSummary | null
|
|
216
216
|
): Promise<string[]> {
|
|
217
217
|
if (candidates.length === 0 && !ratings) return [];
|
|
218
|
-
if (!
|
|
218
|
+
if (!canInfer()) {
|
|
219
219
|
return candidates
|
|
220
220
|
.slice(0, 3)
|
|
221
221
|
.map(
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { paths } from "./paths";
|
|
5
|
+
|
|
6
|
+
interface HandoffEntry {
|
|
7
|
+
title?: string;
|
|
8
|
+
handoff?: string;
|
|
9
|
+
status?: string;
|
|
10
|
+
timestamp?: string;
|
|
11
|
+
lastUser?: string;
|
|
12
|
+
lastAssistant?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const HANDOFF_STALE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const ELSEWHERE_MAX_CHARS = 300;
|
|
17
|
+
|
|
18
|
+
function readHandoffs(): Record<string, HandoffEntry> {
|
|
19
|
+
const p = resolve(paths.state(), "last-handoff.json");
|
|
20
|
+
if (!existsSync(p)) return {};
|
|
21
|
+
try {
|
|
22
|
+
return JSON.parse(readFileSync(p, "utf-8"));
|
|
23
|
+
} catch {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isFresh(entry: HandoffEntry, now: number): boolean {
|
|
29
|
+
if (!(entry.handoff || entry.lastUser) || !entry.timestamp) return false;
|
|
30
|
+
return now - new Date(entry.timestamp).getTime() <= HANDOFF_STALE_MS;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isScratchDir(dir: string): boolean {
|
|
34
|
+
return dir.startsWith(tmpdir());
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function trimAtWord(text: string, max: number): string {
|
|
38
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
39
|
+
if (flat.length <= max) return flat;
|
|
40
|
+
const cut = flat.slice(0, max);
|
|
41
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
42
|
+
return `${lastSpace > 0 ? cut.slice(0, lastSpace) : cut}…`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatAgo(timestamp: string, now: number): string {
|
|
46
|
+
const minutes = Math.floor((now - new Date(timestamp).getTime()) / 60_000);
|
|
47
|
+
if (minutes < 60) return `${minutes}m ago`;
|
|
48
|
+
const hours = Math.floor(minutes / 60);
|
|
49
|
+
if (hours < 24) return `${hours}h ago`;
|
|
50
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lastExchangeLines(entry: HandoffEntry): string[] {
|
|
54
|
+
if (!entry.lastUser) return [];
|
|
55
|
+
const lines = ["Last exchange:", `- User: ${entry.lastUser}`];
|
|
56
|
+
if (entry.lastAssistant) lines.push(`- You: ${entry.lastAssistant}`);
|
|
57
|
+
return lines;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A finished session is still shown, for follow-ups, just not as open work. */
|
|
61
|
+
function hereSection(entry: HandoffEntry | undefined, now: number): string {
|
|
62
|
+
if (!entry || !isFresh(entry, now)) return "";
|
|
63
|
+
const open = entry.status !== "completed";
|
|
64
|
+
return [
|
|
65
|
+
"## Pick Up Where You Left Off",
|
|
66
|
+
`*Previous session: ${entry.title} · ${formatAgo(entry.timestamp ?? "", now)}*`,
|
|
67
|
+
...(entry.handoff ? [entry.handoff] : []),
|
|
68
|
+
...lastExchangeLines(entry),
|
|
69
|
+
...(open
|
|
70
|
+
? ["→ Continue this work or explicitly close it before starting something new."]
|
|
71
|
+
: []),
|
|
72
|
+
].join("\n");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function latestElsewhere(
|
|
76
|
+
handoffs: Record<string, HandoffEntry>,
|
|
77
|
+
here: string,
|
|
78
|
+
now: number
|
|
79
|
+
): [string, HandoffEntry] | undefined {
|
|
80
|
+
return Object.entries(handoffs)
|
|
81
|
+
.filter(([dir, entry]) => dir !== here && !isScratchDir(dir) && isFresh(entry, now))
|
|
82
|
+
.sort(([, a], [, b]) => (b.timestamp ?? "").localeCompare(a.timestamp ?? ""))[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function elsewhereSection(
|
|
86
|
+
handoffs: Record<string, HandoffEntry>,
|
|
87
|
+
here: string,
|
|
88
|
+
now: number
|
|
89
|
+
): string {
|
|
90
|
+
const latest = latestElsewhere(handoffs, here, now);
|
|
91
|
+
if (!latest) return "";
|
|
92
|
+
const [dir, entry] = latest;
|
|
93
|
+
const hereEntry = handoffs[here];
|
|
94
|
+
if (hereEntry?.timestamp && hereEntry.timestamp > (entry.timestamp ?? "")) return "";
|
|
95
|
+
return [
|
|
96
|
+
"## Last Conversation Elsewhere",
|
|
97
|
+
`*${dir} · ${formatAgo(entry.timestamp ?? "", now)} · may be unrelated: ${entry.title}*`,
|
|
98
|
+
trimAtWord(entry.handoff || `User: ${entry.lastUser}`, ELSEWHERE_MAX_CHARS),
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* This folder's handoff comes first and in full, since it is what a session
|
|
104
|
+
* started here usually continues. The latest conversation from any other
|
|
105
|
+
* folder follows in brief, only when it is newer than this folder's.
|
|
106
|
+
*/
|
|
107
|
+
export function loadHandoffContext(here: string, now: number = Date.now()): string {
|
|
108
|
+
const handoffs = readHandoffs();
|
|
109
|
+
return [hereSection(handoffs[here], now), elsewhereSection(handoffs, here, now)]
|
|
110
|
+
.filter(Boolean)
|
|
111
|
+
.join("\n\n");
|
|
112
|
+
}
|
|
@@ -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 */
|
|
@@ -391,10 +391,14 @@ const MAX_INLINE_BULLETS = 3;
|
|
|
391
391
|
* Format the SessionStart "Active Projects" section.
|
|
392
392
|
*
|
|
393
393
|
* For the cwd-resolved project (`→ here`): full detail with Context, Objectives
|
|
394
|
-
* (from goal section), Next, and Blockers.
|
|
395
|
-
*
|
|
394
|
+
* (from goal section), Next, and Blockers. Other recent projects: one line with
|
|
395
|
+
* counts and the last session's title, enough to follow up from any folder.
|
|
396
|
+
* Quiet ones (>14d) collapse to a single line of names.
|
|
396
397
|
*/
|
|
397
|
-
export function loadActiveProjectsContext(
|
|
398
|
+
export function loadActiveProjectsContext(
|
|
399
|
+
cwd: string = process.cwd(),
|
|
400
|
+
lastSessionTitle: (project: string) => string | undefined = () => undefined
|
|
401
|
+
): string {
|
|
398
402
|
const all = readAllProjects();
|
|
399
403
|
const visible = all.filter((p) => p.status === "active" || p.status === "paused");
|
|
400
404
|
const resolved = resolveProjectFromCwd(cwd, visible);
|
|
@@ -411,6 +415,7 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
|
|
|
411
415
|
if (visible.length > 0) {
|
|
412
416
|
lines.push("## Active Projects", "");
|
|
413
417
|
const sorted = [...visible].sort((a, b) => b.updated.localeCompare(a.updated));
|
|
418
|
+
const quiet: string[] = [];
|
|
414
419
|
for (const p of sorted) {
|
|
415
420
|
const ago = formatAgo(p.updated);
|
|
416
421
|
const stale = isStale(p) ? " ⚠ stale" : "";
|
|
@@ -447,14 +452,21 @@ export function loadActiveProjectsContext(cwd: string = process.cwd()): string {
|
|
|
447
452
|
if (p.blockers?.length) {
|
|
448
453
|
lines.push(` Blockers: ${p.blockers.slice(0, MAX_INLINE_BULLETS).join("; ")}`);
|
|
449
454
|
}
|
|
455
|
+
} else if (stale) {
|
|
456
|
+
quiet.push(p.name);
|
|
450
457
|
} else {
|
|
451
458
|
const counts: string[] = [];
|
|
452
459
|
if (p.next?.length) counts.push(`${p.next.length} next`);
|
|
453
460
|
if (p.blockers?.length) counts.push(`${p.blockers.length} blockers`);
|
|
454
461
|
const countsSuffix = counts.length > 0 ? ` — ${counts.join(", ")}` : "";
|
|
455
|
-
|
|
462
|
+
const last = lastSessionTitle(p.name);
|
|
463
|
+
const lastSuffix = last ? ` — last: ${last}` : "";
|
|
464
|
+
lines.push(`- **${p.name}** (${statusPrefix}${ago})${countsSuffix}${lastSuffix}`);
|
|
456
465
|
}
|
|
457
466
|
}
|
|
467
|
+
if (quiet.length > 0) {
|
|
468
|
+
lines.push(`- Quiet for ${PROJECT_STALE_DAYS_DEFAULT}+ days: ${quiet.join(", ")}`);
|
|
469
|
+
}
|
|
458
470
|
}
|
|
459
471
|
|
|
460
472
|
if (showHint) {
|
|
@@ -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) {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn } from "node:child_process";
|
|
11
|
-
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
13
|
import { SIMILARITY_THRESHOLD } from "./graduation";
|
|
14
14
|
import { readFailures, readReflections } from "./learning-store";
|
|
@@ -197,14 +197,21 @@ export function readIndex(): RetrievalIndex | null {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
/** True if any source directory was modified after the index was built. */
|
|
200
|
+
function newestFileMtime(dir: string): number {
|
|
201
|
+
let newest = 0;
|
|
202
|
+
for (const entry of readdirSync(dir, { recursive: true, withFileTypes: true })) {
|
|
203
|
+
if (!entry.isFile()) continue;
|
|
204
|
+
newest = Math.max(newest, statSync(resolve(entry.parentPath, entry.name)).mtimeMs);
|
|
205
|
+
}
|
|
206
|
+
return newest;
|
|
207
|
+
}
|
|
208
|
+
|
|
200
209
|
export function isStale(index: RetrievalIndex): boolean {
|
|
201
210
|
try {
|
|
202
211
|
const builtMs = new Date(index.builtAt).getTime();
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
return false;
|
|
212
|
+
return [paths.failures(), paths.wisdom(), paths.reflections()].some(
|
|
213
|
+
(dir) => newestFileMtime(dir) > builtMs
|
|
214
|
+
);
|
|
208
215
|
} catch {
|
|
209
216
|
return false;
|
|
210
217
|
}
|
|
@@ -79,9 +79,14 @@ const WIN_ELEVATED_THREATS: [RegExp, string][] = [
|
|
|
79
79
|
[/\bdiskpart\b/i, "Disk partitioning"],
|
|
80
80
|
];
|
|
81
81
|
|
|
82
|
+
const POSIX_ROOT_TARGET = String.raw`["']?(?:(?:~|\$HOME|\$\{HOME\})/?|/)\*?["']?(?=\s|[;&|]|$)`;
|
|
83
|
+
|
|
82
84
|
/** Dangerous command patterns — always blocked */
|
|
83
85
|
const BLOCKED_COMMANDS: [RegExp, string][] = [
|
|
84
|
-
[
|
|
86
|
+
[
|
|
87
|
+
new RegExp(String.raw`rm\s+-rf\s+${POSIX_ROOT_TARGET}`),
|
|
88
|
+
"Recursive delete of root or home",
|
|
89
|
+
],
|
|
85
90
|
[/mkfs\./, "Filesystem format"],
|
|
86
91
|
[/dd\s+if=.*of=\/dev\//, "Raw disk write"],
|
|
87
92
|
[/>\s*\/dev\/sd/, "Direct device write"],
|
|
@@ -232,6 +237,28 @@ function managedReasonInSegment(segment: string): string | null {
|
|
|
232
237
|
const READ_ONLY_COMMANDS =
|
|
233
238
|
/^\s*(?:cat|head|tail|less|more|grep|rg|wc|diff|stat|file|ls|dir|find|git\s+(?:log|diff|blame|show|status)|bat)\b/;
|
|
234
239
|
|
|
240
|
+
const COMMAND_SEPARATORS = new Set(["|", ";", "&"]);
|
|
241
|
+
|
|
242
|
+
function unquotedSegments(cmd: string): string[] {
|
|
243
|
+
const segments: string[] = [];
|
|
244
|
+
let current = "";
|
|
245
|
+
let quote: string | null = null;
|
|
246
|
+
for (const char of cmd) {
|
|
247
|
+
if (quote) {
|
|
248
|
+
if (char === quote) quote = null;
|
|
249
|
+
} else if (char === "'" || char === '"') {
|
|
250
|
+
quote = char;
|
|
251
|
+
} else if (COMMAND_SEPARATORS.has(char)) {
|
|
252
|
+
segments.push(current.trim());
|
|
253
|
+
current = "";
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
current += char;
|
|
257
|
+
}
|
|
258
|
+
segments.push(current.trim());
|
|
259
|
+
return segments;
|
|
260
|
+
}
|
|
261
|
+
|
|
235
262
|
/** Check a bash command against blocked patterns. Returns reason string or null. */
|
|
236
263
|
export function checkBashCommand(cmd: string): string | null {
|
|
237
264
|
for (const [pattern, reason] of BLOCKED_COMMANDS) {
|
|
@@ -242,8 +269,7 @@ export function checkBashCommand(cmd: string): string | null {
|
|
|
242
269
|
if (pattern.test(cmd)) return reason;
|
|
243
270
|
}
|
|
244
271
|
}
|
|
245
|
-
const
|
|
246
|
-
for (const segment of segments) {
|
|
272
|
+
for (const segment of unquotedSegments(cmd)) {
|
|
247
273
|
const reason = managedReasonInSegment(segment);
|
|
248
274
|
if (reason && !READ_ONLY_COMMANDS.test(segment)) return reason;
|
|
249
275
|
}
|