portable-agent-layer 0.35.0 → 0.37.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 +2 -1
- package/assets/skills/analyze-pdf/tools/pdf-download.ts +1 -1
- package/assets/skills/analyze-youtube/tools/youtube-analyze.ts +1 -1
- package/assets/skills/consulting-report/tools/dev.ts +2 -2
- package/assets/skills/consulting-report/tools/generate-pdf.ts +9 -9
- package/assets/skills/consulting-report/tools/scaffold.ts +2 -2
- package/assets/skills/create-pdf/tools/md-to-html-pdf.ts +2 -2
- package/assets/skills/opinion/tools/opinion.ts +3 -2
- package/assets/skills/presentation/SKILL.md +1 -1
- package/assets/skills/presentation/tools/doctor.ts +2 -5
- package/assets/skills/presentation/tools/lib/inline.ts +6 -11
- package/assets/skills/presentation/tools/lib/lint-helpers.ts +2 -2
- package/assets/skills/presentation/tools/lib/lint-rules.ts +5 -2
- package/assets/skills/presentation/tools/setup-template.ts +10 -7
- package/assets/skills/projects/SKILL.md +44 -21
- package/assets/skills/research/tools/gemini-search.ts +2 -2
- package/assets/skills/research/tools/grok-search.ts +2 -2
- package/assets/skills/research/tools/perplexity-search.ts +2 -2
- package/assets/skills/telos/SKILL.md +7 -52
- package/assets/skills/telos/tools/update-telos.ts +0 -1
- package/assets/templates/PAL/ALGORITHM.md +54 -5
- package/assets/templates/PAL/PROJECT_LIFECYCLE.md +48 -0
- package/assets/templates/PAL/README.md +1 -1
- package/assets/templates/PAL/STEERING_RULES.md +4 -0
- package/assets/templates/PAL/SYSTEM_ARCHITECTURE.md +32 -17
- package/assets/templates/PAL/WORK_TRACKING.md +1 -1
- package/assets/templates/hooks.codex.json +44 -0
- package/assets/templates/hooks.cursor.json +11 -5
- package/assets/templates/pal-settings.json +1 -3
- package/assets/templates/settings.claude.json +2 -1
- package/package.json +2 -1
- package/src/cli/index.ts +112 -14
- package/src/cli/migrate.ts +299 -0
- package/src/cli/setup-identity.ts +3 -3
- package/src/cli/setup-telos.ts +12 -80
- package/src/hooks/CompactRecover.ts +11 -5
- package/src/hooks/LoadContext.ts +35 -11
- package/src/hooks/PreCompactPersist.ts +26 -34
- package/src/hooks/SecurityValidator.ts +43 -21
- package/src/hooks/StopOrchestrator.ts +4 -1
- package/src/hooks/UserPromptOrchestrator.ts +4 -2
- package/src/hooks/handlers/auto-graduate.ts +2 -2
- package/src/hooks/handlers/backup.ts +3 -3
- package/src/hooks/handlers/context-digests.ts +74 -0
- package/src/hooks/handlers/failure.ts +5 -3
- package/src/hooks/handlers/inject-retrieval.ts +29 -6
- package/src/hooks/handlers/persist-last-exchange.ts +76 -0
- package/src/hooks/handlers/rating.ts +2 -1
- package/src/hooks/handlers/readme-sync.ts +3 -2
- package/src/hooks/handlers/session-intelligence.ts +17 -93
- package/src/hooks/handlers/session-name.ts +2 -2
- package/src/hooks/handlers/synthesis.ts +5 -2
- package/src/hooks/handlers/update-counts.ts +3 -2
- package/src/hooks/lib/agent.ts +20 -18
- package/src/hooks/lib/claude-md.ts +69 -14
- package/src/hooks/lib/context.ts +92 -246
- package/src/hooks/lib/entities.ts +7 -7
- package/src/hooks/lib/frontmatter.ts +4 -4
- package/src/hooks/lib/graduation.ts +7 -6
- package/src/hooks/lib/inference.ts +6 -2
- package/src/hooks/lib/learning-category.ts +1 -1
- package/src/hooks/lib/learning-store.ts +6 -1
- package/src/hooks/lib/notify.ts +2 -2
- package/src/hooks/lib/opinions.ts +3 -3
- package/src/hooks/lib/paths.ts +2 -0
- package/src/hooks/lib/projects.ts +142 -74
- package/src/hooks/lib/readme-sync.ts +1 -1
- package/src/hooks/lib/relationship.ts +4 -16
- package/src/hooks/lib/retrieval-index.ts +5 -3
- package/src/hooks/lib/retrieval.ts +11 -12
- package/src/hooks/lib/security.ts +24 -18
- package/src/hooks/lib/semi-static.ts +188 -0
- package/src/hooks/lib/session-names.ts +1 -1
- package/src/hooks/lib/settings.ts +1 -1
- package/src/hooks/lib/setup.ts +2 -65
- package/src/hooks/lib/signals.ts +2 -2
- package/src/hooks/lib/stdin.ts +1 -1
- package/src/hooks/lib/stop.ts +16 -6
- package/src/hooks/lib/token-usage.ts +1 -2
- package/src/hooks/lib/transcript.ts +1 -1
- package/src/hooks/lib/wisdom.ts +5 -5
- package/src/hooks/lib/work-tracking.ts +8 -14
- package/src/targets/claude/uninstall.ts +1 -1
- package/src/targets/codex/install.ts +95 -0
- package/src/targets/codex/uninstall.ts +70 -0
- package/src/targets/copilot/install.ts +39 -8
- package/src/targets/copilot/uninstall.ts +58 -17
- package/src/targets/cursor/install.ts +8 -0
- package/src/targets/cursor/uninstall.ts +18 -1
- package/src/targets/lib.ts +166 -14
- package/src/targets/opencode/install.ts +29 -1
- package/src/targets/opencode/plugin.ts +23 -12
- package/src/targets/opencode/uninstall.ts +30 -3
- package/src/tools/agent/algorithm-reflect.ts +1 -1
- package/src/tools/agent/analyze.ts +18 -18
- package/src/tools/agent/handoff-note.ts +116 -0
- package/src/tools/agent/project.ts +375 -75
- package/src/tools/agent/relationship-note.ts +51 -0
- package/src/tools/agent/synthesize.ts +6 -42
- package/src/tools/agent/thread.ts +15 -14
- package/src/tools/agent/wisdom-frame.ts +9 -3
- package/src/tools/import.ts +1 -1
- package/src/tools/relationship-reflect.ts +15 -13
- package/src/tools/self-model.ts +23 -19
- package/src/tools/session-summary.ts +3 -3
- package/src/tools/token-cost.ts +15 -16
- package/assets/skills/telos/tools/update-projects.ts +0 -106
- package/assets/templates/telos/PROJECTS.md +0 -7
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared handler: persist the last user/assistant exchange on every Stop and PreCompact.
|
|
3
|
+
*
|
|
4
|
+
* Writes two outputs:
|
|
5
|
+
* 1. last-exchange/{sessionId}.json + last-exchange/latest.json
|
|
6
|
+
* → read by CompactRecover to re-inject after compaction
|
|
7
|
+
* 2. last-handoff.json keyed by cwd
|
|
8
|
+
* → read by loadHandoff() to surface "Pick Up Where You Left Off"
|
|
9
|
+
*
|
|
10
|
+
* Always overwrites — Stop is the source of truth for both. The LEARN-phase
|
|
11
|
+
* handoff-note.ts tool may also write to last-handoff.json; whichever runs last wins,
|
|
12
|
+
* but raw exchange is sufficient for continuity and costs nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { logDebug, logError } from "../lib/log";
|
|
18
|
+
import { ensureDir, paths } from "../lib/paths";
|
|
19
|
+
import { extractContent, extractLastAssistant, extractLastUser } from "../lib/transcript";
|
|
20
|
+
import { detectStatus } from "../lib/work-tracking";
|
|
21
|
+
|
|
22
|
+
type ParsedMessage = { role: string; content: unknown };
|
|
23
|
+
|
|
24
|
+
export function persistLastExchange(
|
|
25
|
+
messages: ParsedMessage[],
|
|
26
|
+
sessionId: string,
|
|
27
|
+
cwd: string = process.cwd()
|
|
28
|
+
): void {
|
|
29
|
+
try {
|
|
30
|
+
const lastUser = extractContent(extractLastUser(messages));
|
|
31
|
+
const lastAssistant = extractContent(extractLastAssistant(messages));
|
|
32
|
+
if (!lastUser && !lastAssistant) return;
|
|
33
|
+
|
|
34
|
+
// 1. Write last-exchange files for CompactRecover
|
|
35
|
+
const stateDir = ensureDir(resolve(paths.state(), "last-exchange"));
|
|
36
|
+
const payload = {
|
|
37
|
+
sessionId,
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
trigger: null,
|
|
40
|
+
customInstructions: null,
|
|
41
|
+
userMessage: lastUser,
|
|
42
|
+
assistantMessage: lastAssistant,
|
|
43
|
+
};
|
|
44
|
+
const json = `${JSON.stringify(payload, null, 2)}\n`;
|
|
45
|
+
writeFileSync(resolve(stateDir, `${sessionId}.json`), json, "utf-8");
|
|
46
|
+
writeFileSync(resolve(stateDir, "latest.json"), json, "utf-8");
|
|
47
|
+
|
|
48
|
+
// 2. Write last-handoff.json for "Pick Up Where You Left Off"
|
|
49
|
+
const handoffPath = resolve(paths.state(), "last-handoff.json");
|
|
50
|
+
const existing: Record<string, unknown> = existsSync(handoffPath)
|
|
51
|
+
? JSON.parse(readFileSync(handoffPath, "utf-8"))
|
|
52
|
+
: {};
|
|
53
|
+
const title = (lastUser.slice(0, 80).replace(/\n/g, " ") || "Session").trim();
|
|
54
|
+
const handoff = [
|
|
55
|
+
lastUser ? `Last user message:\n${lastUser.slice(0, 500)}` : "",
|
|
56
|
+
lastAssistant ? `\nLast assistant response:\n${lastAssistant.slice(0, 500)}` : "",
|
|
57
|
+
]
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.join("");
|
|
60
|
+
existing[cwd] = {
|
|
61
|
+
timestamp: new Date().toISOString(),
|
|
62
|
+
title,
|
|
63
|
+
status: detectStatus(lastAssistant),
|
|
64
|
+
handoff,
|
|
65
|
+
artifacts: [],
|
|
66
|
+
};
|
|
67
|
+
writeFileSync(handoffPath, JSON.stringify(existing, null, 2), "utf-8");
|
|
68
|
+
|
|
69
|
+
logDebug(
|
|
70
|
+
"persist-last-exchange",
|
|
71
|
+
`Persisted exchange for session ${sessionId} (user=${lastUser.length}ch, assistant=${lastAssistant.length}ch)`
|
|
72
|
+
);
|
|
73
|
+
} catch (err) {
|
|
74
|
+
logError("persist-last-exchange", err);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -48,7 +48,7 @@ export function parseExplicitRating(
|
|
|
48
48
|
prompt: string
|
|
49
49
|
): { rating: number; comment?: string } | null {
|
|
50
50
|
const trimmed = prompt.trim();
|
|
51
|
-
const match =
|
|
51
|
+
const match = new RegExp(/^(10|[1-9])(?:\s*[-:,]\s*|\s+)?(.*)$/).exec(trimmed);
|
|
52
52
|
if (!match) return null;
|
|
53
53
|
|
|
54
54
|
const rating = parseInt(match[1], 10);
|
|
@@ -271,6 +271,7 @@ function handleRating(
|
|
|
271
271
|
principle,
|
|
272
272
|
responsePreview,
|
|
273
273
|
userPreview,
|
|
274
|
+
cwd: process.cwd(),
|
|
274
275
|
ts: now(),
|
|
275
276
|
},
|
|
276
277
|
null,
|
|
@@ -33,7 +33,7 @@ function hasDocumentableChanges(): boolean {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
interface ReadmeSyncDecision {
|
|
37
37
|
decision?: "block";
|
|
38
38
|
reason?: string;
|
|
39
39
|
}
|
|
@@ -50,9 +50,10 @@ export function checkReadmeSync(): ReadmeSyncDecision {
|
|
|
50
50
|
|
|
51
51
|
if (!result.ok) {
|
|
52
52
|
logDebug("readme-sync", `README out of sync: ${result.issues.join("; ")}`);
|
|
53
|
+
const issueList = result.issues.map((i) => `- ${i}`).join("\n");
|
|
53
54
|
return {
|
|
54
55
|
decision: "block",
|
|
55
|
-
reason: `README.md is out of date. Please update it before finishing:\n${
|
|
56
|
+
reason: `README.md is out of date. Please update it before finishing:\n${issueList}`,
|
|
56
57
|
};
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Stop handler: unified session intelligence capture.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Produces: title, summary, insights via Haiku.
|
|
5
|
+
* Writes: session learning file, project history.
|
|
6
|
+
*
|
|
7
|
+
* Relationship notes → written in ALGORITHM LEARN phase via relationship-note.ts
|
|
8
|
+
* Handoff notes → written in ALGORITHM LEARN phase via handoff-note.ts
|
|
7
9
|
*
|
|
8
|
-
* Replaces: work-learning.ts + relationship.ts (both still exist but are bypassed).
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
|
-
import { existsSync, readFileSync,
|
|
12
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { unlink, writeFile } from "node:fs/promises";
|
|
12
14
|
import { resolve } from "node:path";
|
|
13
15
|
import { stringify } from "../lib/frontmatter";
|
|
14
|
-
import { inference } from "../lib/inference";
|
|
16
|
+
import { hasApiKey, inference } from "../lib/inference";
|
|
15
17
|
import { categorizeLearning } from "../lib/learning-category";
|
|
16
18
|
import { logDebug, logError } from "../lib/log";
|
|
17
19
|
import { ensureDir, paths } from "../lib/paths";
|
|
18
|
-
import { appendNotes, hasSessionNotes, type RelationshipNote } from "../lib/relationship";
|
|
19
20
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
20
21
|
import { logTokenUsage } from "../lib/token-usage";
|
|
21
22
|
import {
|
|
@@ -26,7 +27,7 @@ import {
|
|
|
26
27
|
} from "../lib/transcript";
|
|
27
28
|
import { appendProjectHistory, detectStatus } from "../lib/work-tracking";
|
|
28
29
|
|
|
29
|
-
// ── Dedup tracking
|
|
30
|
+
// ── Dedup tracking ──
|
|
30
31
|
|
|
31
32
|
interface CaptureEntry {
|
|
32
33
|
filepath: string;
|
|
@@ -110,25 +111,8 @@ const INTELLIGENCE_SCHEMA = {
|
|
|
110
111
|
description:
|
|
111
112
|
"If status is in-progress: what remains to be done, key decisions made, blockers. If completed: empty string.",
|
|
112
113
|
},
|
|
113
|
-
observations: {
|
|
114
|
-
type: "array" as const,
|
|
115
|
-
items: {
|
|
116
|
-
type: "object" as const,
|
|
117
|
-
additionalProperties: false,
|
|
118
|
-
properties: {
|
|
119
|
-
type: {
|
|
120
|
-
type: "string" as const,
|
|
121
|
-
enum: ["O", "W", "B"],
|
|
122
|
-
description: "O=preference, W=world fact, B=what AI did",
|
|
123
|
-
},
|
|
124
|
-
text: { type: "string" as const },
|
|
125
|
-
confidence: { type: "number" as const },
|
|
126
|
-
},
|
|
127
|
-
required: ["type", "text", "confidence"] as const,
|
|
128
|
-
},
|
|
129
|
-
},
|
|
130
114
|
},
|
|
131
|
-
required: ["title", "summary", "insights", "handoff"
|
|
115
|
+
required: ["title", "summary", "insights", "handoff"] as const,
|
|
132
116
|
};
|
|
133
117
|
|
|
134
118
|
interface IntelligenceOutput {
|
|
@@ -136,7 +120,6 @@ interface IntelligenceOutput {
|
|
|
136
120
|
summary: string;
|
|
137
121
|
insights: string;
|
|
138
122
|
handoff: string;
|
|
139
|
-
observations: Array<{ type: "O" | "W" | "B"; text: string; confidence: number }>;
|
|
140
123
|
}
|
|
141
124
|
|
|
142
125
|
// ── Main handler ──
|
|
@@ -155,14 +138,11 @@ export async function captureSessionIntelligence(
|
|
|
155
138
|
}
|
|
156
139
|
|
|
157
140
|
// Skip if no API key
|
|
158
|
-
if (!
|
|
141
|
+
if (!hasApiKey()) {
|
|
159
142
|
logDebug("session-intelligence", "Skipped: no PAL_ANTHROPIC_API_KEY");
|
|
160
143
|
return;
|
|
161
144
|
}
|
|
162
145
|
|
|
163
|
-
// Relationship dedup — skip relationship capture if already done for this session
|
|
164
|
-
const skipRelationship = sessionId ? hasSessionNotes(sessionId) : false;
|
|
165
|
-
|
|
166
146
|
// Extract transcript windows
|
|
167
147
|
const userMessages = messages
|
|
168
148
|
.filter((m) => m.role === "user")
|
|
@@ -174,14 +154,14 @@ export async function captureSessionIntelligence(
|
|
|
174
154
|
const lastUser = extractLastUser(messages);
|
|
175
155
|
const status = detectStatus(lastAssistantText);
|
|
176
156
|
|
|
177
|
-
|
|
178
|
-
const userWindow = userMessages.slice(-15).map((t) => t.slice(0, 200));
|
|
157
|
+
const userWindow = userMessages.slice(-10).map((t) => t.slice(0, 200));
|
|
179
158
|
const assistantWindow = lastAssistantText.slice(0, 600);
|
|
180
159
|
|
|
181
160
|
if (userWindow.length < 3) return;
|
|
182
161
|
|
|
183
162
|
// Single Haiku call
|
|
184
163
|
logDebug("session-intelligence", "Calling inference...");
|
|
164
|
+
const numberedMessages = userWindow.map((m, i) => `${i + 1}. ${m}`).join("\n");
|
|
185
165
|
let output: IntelligenceOutput | null = null;
|
|
186
166
|
try {
|
|
187
167
|
const result = await inference({
|
|
@@ -195,12 +175,9 @@ export async function captureSessionIntelligence(
|
|
|
195
175
|
status === "in-progress"
|
|
196
176
|
? "4. handoff: what remains unfinished — decisions made so far, next steps, blockers (2-4 sentences)"
|
|
197
177
|
: "4. handoff: empty string (session completed)",
|
|
198
|
-
skipRelationship
|
|
199
|
-
? "5. observations: empty array (already captured)"
|
|
200
|
-
: "5. observations: 0-3 relationship observations. O=preference/opinion, W=world fact, B=what AI did this session (first-person). Be concise.",
|
|
201
178
|
].join("\n"),
|
|
202
|
-
user: `User messages:\n${
|
|
203
|
-
maxTokens:
|
|
179
|
+
user: `User messages:\n${numberedMessages}\n\nLast AI response:\n${assistantWindow}`,
|
|
180
|
+
maxTokens: 350,
|
|
204
181
|
timeout: 15000,
|
|
205
182
|
jsonSchema: INTELLIGENCE_SCHEMA,
|
|
206
183
|
});
|
|
@@ -218,8 +195,6 @@ export async function captureSessionIntelligence(
|
|
|
218
195
|
const title = output?.title || extractContent(lastUser).slice(0, 80) || "session";
|
|
219
196
|
const summary = output?.summary || lastAssistantText.slice(0, 600);
|
|
220
197
|
const insights = output?.insights || "";
|
|
221
|
-
const handoff = output?.handoff || "";
|
|
222
|
-
|
|
223
198
|
// ── Write session learning file ──
|
|
224
199
|
|
|
225
200
|
const category = categorizeLearning(title, summary);
|
|
@@ -241,7 +216,6 @@ export async function captureSessionIntelligence(
|
|
|
241
216
|
"",
|
|
242
217
|
"## Insights",
|
|
243
218
|
insights || "*No insights captured.*",
|
|
244
|
-
...(handoff ? ["", "## Handoff", handoff] : []),
|
|
245
219
|
].join("\n");
|
|
246
220
|
|
|
247
221
|
const content = stringify(meta, body);
|
|
@@ -251,7 +225,7 @@ export async function captureSessionIntelligence(
|
|
|
251
225
|
const prev = getPreviousCapture(sessionId);
|
|
252
226
|
if (prev?.filepath && existsSync(prev.filepath)) {
|
|
253
227
|
try {
|
|
254
|
-
|
|
228
|
+
await unlink(prev.filepath);
|
|
255
229
|
} catch {
|
|
256
230
|
/* ignore */
|
|
257
231
|
}
|
|
@@ -259,7 +233,7 @@ export async function captureSessionIntelligence(
|
|
|
259
233
|
}
|
|
260
234
|
|
|
261
235
|
const filepath = resolve(dir, filename);
|
|
262
|
-
|
|
236
|
+
await writeFile(filepath, content, "utf-8");
|
|
263
237
|
|
|
264
238
|
// Append to per-project history
|
|
265
239
|
appendProjectHistory(process.cwd(), {
|
|
@@ -271,54 +245,4 @@ export async function captureSessionIntelligence(
|
|
|
271
245
|
|
|
272
246
|
if (sessionId) markCaptured(sessionId, filepath, messages.length);
|
|
273
247
|
logDebug("session-intelligence", `Learning captured: ${title}`);
|
|
274
|
-
|
|
275
|
-
// ── Write relationship notes ──
|
|
276
|
-
|
|
277
|
-
if (!skipRelationship && output?.observations && output.observations.length > 0) {
|
|
278
|
-
try {
|
|
279
|
-
const notes: RelationshipNote[] = output.observations.map((o) => ({
|
|
280
|
-
type: o.type,
|
|
281
|
-
text: o.text,
|
|
282
|
-
confidence: o.confidence,
|
|
283
|
-
}));
|
|
284
|
-
appendNotes(notes, sessionId);
|
|
285
|
-
logDebug(
|
|
286
|
-
"session-intelligence",
|
|
287
|
-
`${notes.length} relationship observations captured`
|
|
288
|
-
);
|
|
289
|
-
} catch (err) {
|
|
290
|
-
logError("session-intelligence:relationship", err);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
// ── Write handoff state ──
|
|
295
|
-
|
|
296
|
-
if (handoff && status === "in-progress") {
|
|
297
|
-
try {
|
|
298
|
-
const handoffPath = resolve(ensureDir(paths.state()), "last-handoff.json");
|
|
299
|
-
let handoffs: Record<string, unknown> = {};
|
|
300
|
-
if (existsSync(handoffPath)) {
|
|
301
|
-
try {
|
|
302
|
-
handoffs = JSON.parse(readFileSync(handoffPath, "utf-8"));
|
|
303
|
-
} catch {
|
|
304
|
-
/* fresh */
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
handoffs[process.cwd()] = {
|
|
308
|
-
timestamp: new Date().toISOString(),
|
|
309
|
-
sessionId,
|
|
310
|
-
title,
|
|
311
|
-
status,
|
|
312
|
-
handoff,
|
|
313
|
-
artifacts: [],
|
|
314
|
-
};
|
|
315
|
-
// Keep last 20 projects
|
|
316
|
-
const entries = Object.entries(handoffs);
|
|
317
|
-
if (entries.length > 20) handoffs = Object.fromEntries(entries.slice(-20));
|
|
318
|
-
writeFileSync(handoffPath, JSON.stringify(handoffs, null, 2), "utf-8");
|
|
319
|
-
logDebug("session-intelligence", "Handoff state written");
|
|
320
|
-
} catch (err) {
|
|
321
|
-
logError("session-intelligence:handoff", err);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
248
|
}
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawn } from "node:child_process";
|
|
13
|
-
import { inference } from "../lib/inference";
|
|
13
|
+
import { hasApiKey, inference } from "../lib/inference";
|
|
14
14
|
import { logDebug, logError } from "../lib/log";
|
|
15
15
|
import {
|
|
16
16
|
extractFallbackName,
|
|
@@ -42,7 +42,7 @@ export async function captureSessionName(
|
|
|
42
42
|
logDebug("session-name", `Named from prompt: "${name}"`);
|
|
43
43
|
|
|
44
44
|
// Spawn detached background process to upgrade with Haiku inference
|
|
45
|
-
if (!
|
|
45
|
+
if (!hasApiKey()) return;
|
|
46
46
|
try {
|
|
47
47
|
const promptB64 = Buffer.from(message.slice(0, 800)).toString("base64");
|
|
48
48
|
const child = spawn(
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Imports synthesize logic directly — no subprocess needed.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { existsSync
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import { readFile } from "node:fs/promises";
|
|
7
8
|
import { resolve } from "node:path";
|
|
8
9
|
import { logDebug } from "../lib/log";
|
|
9
10
|
import { paths } from "../lib/paths";
|
|
@@ -16,7 +17,9 @@ export async function runSynthesis(): Promise<void> {
|
|
|
16
17
|
// Check 24h guard
|
|
17
18
|
if (existsSync(statePath)) {
|
|
18
19
|
try {
|
|
19
|
-
const data = JSON.parse(
|
|
20
|
+
const data = JSON.parse(await readFile(statePath, "utf-8")) as {
|
|
21
|
+
timestamp: string;
|
|
22
|
+
};
|
|
20
23
|
if (Date.now() - new Date(data.timestamp).getTime() < SYNTHESIS_TTL_MS) {
|
|
21
24
|
logDebug("synthesis", "Skipped — last synthesis < 24h ago");
|
|
22
25
|
return;
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* scanning directories and JSONL files.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import { existsSync, readdirSync, readFileSync
|
|
7
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
+
import { writeFile } from "node:fs/promises";
|
|
8
9
|
import { resolve } from "node:path";
|
|
9
10
|
import { assets, ensureDir, paths } from "../lib/paths";
|
|
10
11
|
|
|
@@ -147,5 +148,5 @@ function getCounts(): Counts {
|
|
|
147
148
|
export async function updateCounts(): Promise<void> {
|
|
148
149
|
const counts = getCounts();
|
|
149
150
|
const countsPath = resolve(ensureDir(paths.state()), "counts.json");
|
|
150
|
-
|
|
151
|
+
await writeFile(countsPath, JSON.stringify(counts, null, 2), "utf-8");
|
|
151
152
|
}
|
package/src/hooks/lib/agent.ts
CHANGED
|
@@ -1,40 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Agent detection and output format adapters.
|
|
3
3
|
*
|
|
4
|
-
* Cursor and Claude Code use different JSON contracts for hook I/O.
|
|
4
|
+
* Cursor, Codex, and Claude Code use different JSON contracts for hook I/O.
|
|
5
5
|
* These helpers normalize the differences so hook handlers stay clean.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
type AgentType = "claude" | "cursor" | "codex";
|
|
9
9
|
|
|
10
10
|
/** Detect which agent is running via environment variables */
|
|
11
|
-
|
|
11
|
+
function detectAgent(): AgentType {
|
|
12
|
+
// PAL_AGENT is set explicitly in hook command prefixes — most reliable signal.
|
|
13
|
+
// IDE env vars (CURSOR_VERSION, CODEX_CLI_VERSION) are NOT reliably forwarded to
|
|
14
|
+
// hook subprocesses, so PAL_AGENT is the primary detection mechanism.
|
|
15
|
+
if (process.env.PAL_AGENT === "cursor") return "cursor";
|
|
16
|
+
if (process.env.PAL_AGENT === "codex") return "codex";
|
|
17
|
+
// Fallbacks for environments that do forward IDE env vars
|
|
12
18
|
if (process.env.CURSOR_VERSION) return "cursor";
|
|
19
|
+
if (process.env.CODEX_CLI_VERSION ?? process.env.OPENAI_CODEX) return "codex";
|
|
13
20
|
return "claude";
|
|
14
21
|
}
|
|
15
22
|
|
|
16
23
|
export const isCursor = () => detectAgent() === "cursor";
|
|
24
|
+
export const isCodex = () => detectAgent() === "codex";
|
|
17
25
|
|
|
18
26
|
/**
|
|
19
27
|
* Format a "block this action" response for the current agent.
|
|
20
|
-
* Claude Code:
|
|
21
|
-
* Cursor:
|
|
28
|
+
* Claude Code: { decision: "block", reason }
|
|
29
|
+
* Cursor preToolUse: { permission: "deny", user_message }
|
|
30
|
+
* Codex PreToolUse: { hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny" } }
|
|
22
31
|
*/
|
|
23
|
-
export function blockResponse(reason: string): string {
|
|
32
|
+
export function blockResponse(reason: string, hookEventName?: string): string {
|
|
24
33
|
if (isCursor()) {
|
|
25
34
|
return JSON.stringify({ permission: "deny", user_message: reason });
|
|
26
35
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
* Format sessionStart context injection for the current agent.
|
|
32
|
-
* Claude Code: raw text to stdout
|
|
33
|
-
* Cursor: { additional_context: "..." }
|
|
34
|
-
*/
|
|
35
|
-
export function sessionStartOutput(context: string): string {
|
|
36
|
-
if (isCursor()) {
|
|
37
|
-
return JSON.stringify({ additional_context: context });
|
|
36
|
+
if (isCodex() && hookEventName === "PreToolUse") {
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny" },
|
|
39
|
+
});
|
|
38
40
|
}
|
|
39
|
-
return
|
|
41
|
+
return JSON.stringify({ decision: "block", reason });
|
|
40
42
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dynamic AGENTS.md generation.
|
|
2
|
+
* Dynamic AGENTS.md / CLAUDE.md generation.
|
|
3
3
|
*
|
|
4
|
-
* AGENTS.md is regenerated when setup.json or any
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* AGENTS.md (opencode, codex, copilot) is regenerated when setup.json or any
|
|
5
|
+
* telos file is newer. CLAUDE.md (Claude Code) is a real file — not a symlink —
|
|
6
|
+
* and prepends an @import for the self-model so that large static context loads
|
|
7
|
+
* natively rather than through the hook's stdout.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
import {
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
} from "node:fs";
|
|
20
21
|
import { dirname, relative, resolve } from "node:path";
|
|
21
22
|
import { assets, ensureDir, paths, platform } from "./paths";
|
|
23
|
+
import { getSemiStaticSources } from "./semi-static";
|
|
22
24
|
|
|
23
25
|
const TEMPLATE_PATH = assets.agentsMdTemplate();
|
|
24
26
|
|
|
@@ -70,16 +72,12 @@ function ensureOneSymlink(linkPath: string, targetPath: string): void {
|
|
|
70
72
|
}
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
/** Ensure
|
|
75
|
+
/** Ensure codex symlink points to the canonical AGENTS.md.
|
|
76
|
+
* CLAUDE.md for Claude Code is a real file written by ensureClaudeCodeMd().
|
|
77
|
+
* Copilot uses ~/.copilot/instructions/*.instructions.md — no symlink needed. */
|
|
74
78
|
function ensureSymlinks(): void {
|
|
75
|
-
const { outputPath
|
|
76
|
-
ensureOneSymlink(symlinkPath, outputPath);
|
|
79
|
+
const { outputPath } = getOutputPaths();
|
|
77
80
|
ensureOneSymlink(resolve(platform.codexDir(), "AGENTS.md"), outputPath);
|
|
78
|
-
// Copilot instructions — only create if ~/.copilot/ already exists (i.e. Copilot is installed)
|
|
79
|
-
const copilotDir = platform.copilotDir();
|
|
80
|
-
if (existsSync(copilotDir)) {
|
|
81
|
-
ensureOneSymlink(resolve(copilotDir, "copilot-instructions.md"), outputPath);
|
|
82
|
-
}
|
|
83
81
|
}
|
|
84
82
|
|
|
85
83
|
/** Returns true if AGENTS.md needs to be regenerated */
|
|
@@ -89,11 +87,12 @@ export function needsRebuild(): boolean {
|
|
|
89
87
|
|
|
90
88
|
const outputMtime = statSync(outputPath).mtimeMs;
|
|
91
89
|
|
|
92
|
-
// Collect source files: template + setup.json + identity + PAL docs
|
|
90
|
+
// Collect source files: template + setup.json + identity + PAL docs + @import candidates
|
|
93
91
|
const sources: string[] = [
|
|
94
92
|
TEMPLATE_PATH,
|
|
95
93
|
resolve(paths.state(), "setup.json"),
|
|
96
94
|
resolve(paths.memory(), "pal-settings.json"),
|
|
95
|
+
...getSemiStaticSources().map((s) => s.path),
|
|
97
96
|
];
|
|
98
97
|
|
|
99
98
|
// Track PAL doc sources for rebuild detection
|
|
@@ -124,15 +123,71 @@ export function buildClaudeMd(): string {
|
|
|
124
123
|
.replaceAll("{{PRINCIPAL_NAME}}", id.principal.name);
|
|
125
124
|
}
|
|
126
125
|
|
|
127
|
-
/**
|
|
126
|
+
/** Build @import header lines for CLAUDE.md — one line per semi-static file that exists. */
|
|
127
|
+
function buildClaudeCodeImports(): string {
|
|
128
|
+
const claudeDir = platform.claudeDir();
|
|
129
|
+
|
|
130
|
+
const lines = getSemiStaticSources()
|
|
131
|
+
.map((s) => s.path)
|
|
132
|
+
.filter((p) => existsSync(p))
|
|
133
|
+
.map((p) => `@${relative(claudeDir, p).replaceAll("\\", "/")}`);
|
|
134
|
+
|
|
135
|
+
return lines.length > 0 ? `${lines.join("\n")}\n\n` : "";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Build CLAUDE.md content for Claude Code — prepends @import for self-model. */
|
|
139
|
+
export function buildClaudeCodeMd(): string {
|
|
140
|
+
return buildClaudeCodeImports() + buildClaudeMd();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Write ~/.claude/CLAUDE.md as a real file (upgrading from symlink if needed).
|
|
144
|
+
* Also rewrites if the @import header has changed (new digest files appeared). */
|
|
145
|
+
function ensureClaudeCodeMd(): void {
|
|
146
|
+
const claudeDir = platform.claudeDir();
|
|
147
|
+
if (!claudeDir) return;
|
|
148
|
+
const claudeMdPath = resolve(claudeDir, "CLAUDE.md");
|
|
149
|
+
const expected = buildClaudeCodeMd();
|
|
150
|
+
try {
|
|
151
|
+
if (existsSync(claudeMdPath) && !lstatSync(claudeMdPath).isSymbolicLink()) {
|
|
152
|
+
const current = readFileSync(claudeMdPath, "utf-8");
|
|
153
|
+
if (current === expected) return; // no change needed
|
|
154
|
+
// @imports changed — rewrite
|
|
155
|
+
} else if (existsSync(claudeMdPath)) {
|
|
156
|
+
unlinkSync(claudeMdPath); // remove symlink
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
/* fall through */
|
|
160
|
+
}
|
|
161
|
+
try {
|
|
162
|
+
ensureDir(claudeDir);
|
|
163
|
+
writeFileSync(claudeMdPath, expected, "utf-8");
|
|
164
|
+
} catch {
|
|
165
|
+
/* ignore write errors — non-fatal */
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Regenerate AGENTS.md if any source file is newer, write real CLAUDE.md, ensure other symlinks. Returns true if rebuilt. */
|
|
128
170
|
export function regenerateIfNeeded(): boolean {
|
|
129
171
|
const { outputPath } = getOutputPaths();
|
|
130
172
|
if (!needsRebuild()) {
|
|
131
173
|
ensureSymlinks();
|
|
174
|
+
ensureClaudeCodeMd();
|
|
132
175
|
return false;
|
|
133
176
|
}
|
|
134
177
|
ensureDir(dirname(outputPath));
|
|
135
178
|
writeFileSync(outputPath, buildClaudeMd(), "utf-8");
|
|
179
|
+
// Write Claude Code's CLAUDE.md as a real file (removing any existing symlink)
|
|
180
|
+
const claudeDir = platform.claudeDir();
|
|
181
|
+
if (claudeDir) {
|
|
182
|
+
const claudeMdPath = resolve(claudeDir, "CLAUDE.md");
|
|
183
|
+
try {
|
|
184
|
+
if (existsSync(claudeMdPath)) unlinkSync(claudeMdPath);
|
|
185
|
+
ensureDir(claudeDir);
|
|
186
|
+
writeFileSync(claudeMdPath, buildClaudeCodeMd(), "utf-8");
|
|
187
|
+
} catch {
|
|
188
|
+
/* ignore — CLAUDE.md write failure is non-fatal */
|
|
189
|
+
}
|
|
190
|
+
}
|
|
136
191
|
ensureSymlinks();
|
|
137
192
|
return true;
|
|
138
193
|
}
|