portable-agent-layer 0.2.1 → 0.4.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 +25 -0
- package/package.json +2 -1
- package/src/cli/index.ts +222 -7
- package/src/hooks/StopOrchestrator.ts +12 -0
- package/src/hooks/handlers/failure.ts +49 -44
- package/src/hooks/handlers/rating.ts +12 -18
- package/src/hooks/handlers/readme-sync.ts +61 -0
- package/src/hooks/handlers/work-learning.ts +28 -13
- package/src/hooks/lib/claude-md.ts +2 -1
- package/src/hooks/lib/context.ts +82 -24
- package/src/hooks/lib/frontmatter.ts +95 -0
- package/src/hooks/lib/graduation.ts +499 -0
- package/src/hooks/lib/models.ts +4 -4
- package/src/hooks/lib/readme-sync.ts +129 -0
- package/src/hooks/lib/security.ts +2 -0
- package/src/hooks/lib/tags.ts +89 -0
- package/src/targets/lib.ts +1 -0
- package/src/targets/opencode/plugin.ts +7 -6
- package/src/tools/graduate.ts +51 -0
- package/src/tools/pattern-synthesis.ts +11 -14
- package/src/tools/token-cost.ts +35 -5
package/README.md
CHANGED
|
@@ -30,7 +30,10 @@ With PAL, you can:
|
|
|
30
30
|
|
|
31
31
|
### Prerequisites
|
|
32
32
|
|
|
33
|
+
> **Bun is required.** PAL is built on [Bun](https://bun.sh) and will not work with Node.js or other runtimes. Install it with `curl -fsSL https://bun.sh/install | bash`.
|
|
34
|
+
|
|
33
35
|
- [Bun](https://bun.sh) >= 1.3.0
|
|
36
|
+
- At least one of: [Claude Code](https://claude.ai/code) or [opencode](https://opencode.ai)
|
|
34
37
|
|
|
35
38
|
### Package mode (recommended)
|
|
36
39
|
|
|
@@ -77,6 +80,7 @@ pal cli status # check your setup
|
|
|
77
80
|
| `pal cli export` | Export user state (telos, memory) to a zip |
|
|
78
81
|
| `pal cli import` | Import user state from a zip |
|
|
79
82
|
| `pal cli status` | Show current PAL configuration |
|
|
83
|
+
| `pal cli doctor` | Check prerequisites and system health |
|
|
80
84
|
|
|
81
85
|
### Target flags
|
|
82
86
|
|
|
@@ -111,6 +115,27 @@ pal cli install # both (default)
|
|
|
111
115
|
|
|
112
116
|
---
|
|
113
117
|
|
|
118
|
+
## Skills
|
|
119
|
+
|
|
120
|
+
PAL ships with built-in skills that extend your agent's capabilities:
|
|
121
|
+
|
|
122
|
+
| Skill | Description |
|
|
123
|
+
|-------|-------------|
|
|
124
|
+
| `analyze-pdf` | Download and analyze PDF files |
|
|
125
|
+
| `analyze-youtube` | Analyze YouTube videos using Gemini |
|
|
126
|
+
| `council` | Multi-perspective parallel debate on decisions |
|
|
127
|
+
| `create-skill` | Scaffold a new skill from a description |
|
|
128
|
+
| `extract-entities` | Extract people and companies from content |
|
|
129
|
+
| `extract-wisdom` | Extract structured insights from content |
|
|
130
|
+
| `first-principles` | Break down problems to fundamentals |
|
|
131
|
+
| `fyzz-chat-api` | Query Fyzz Chat conversations via API |
|
|
132
|
+
| `reflect` | Diagnose why a PAL behavior didn't trigger |
|
|
133
|
+
| `research` | Multi-agent parallel research |
|
|
134
|
+
| `review` | Security-focused code review |
|
|
135
|
+
| `summarize` | Structured summarization |
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
114
139
|
## Core idea
|
|
115
140
|
|
|
116
141
|
PAL stands for **Portable Agent Layer**.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "portable-agent-layer",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"ai:fyzz-api": "bun run src/tools/fyzz-api.ts",
|
|
49
49
|
"ai:pdf-download": "bun run src/tools/pdf-download.ts",
|
|
50
50
|
"ai:youtube-analyze": "bun run src/tools/youtube-analyze.ts",
|
|
51
|
+
"tool:graduate": "bun run src/tools/graduate.ts",
|
|
51
52
|
"tool:patterns": "bun run src/tools/pattern-synthesis.ts",
|
|
52
53
|
"tool:reflect": "bun run src/tools/relationship-reflect.ts",
|
|
53
54
|
"tool:export": "bun run src/tools/export.ts",
|
package/src/cli/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
* export [path] [--dry-run] Export user state to zip
|
|
14
14
|
* import [path] [--dry-run] Import user state from zip
|
|
15
15
|
* status Show current PAL configuration
|
|
16
|
+
* doctor Check prerequisites and system health
|
|
16
17
|
*/
|
|
17
18
|
|
|
18
19
|
import { spawnSync } from "node:child_process";
|
|
@@ -20,6 +21,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node
|
|
|
20
21
|
import { homedir } from "node:os";
|
|
21
22
|
import { resolve } from "node:path";
|
|
22
23
|
import { palHome, palPkg, platform } from "../hooks/lib/paths";
|
|
24
|
+
import { getPendingSuggestions } from "../hooks/lib/tags";
|
|
23
25
|
import { log } from "../targets/lib";
|
|
24
26
|
|
|
25
27
|
const allArgs = process.argv.slice(2);
|
|
@@ -35,18 +37,53 @@ if (allArgs[0] === "cli") {
|
|
|
35
37
|
await session(allArgs);
|
|
36
38
|
}
|
|
37
39
|
|
|
38
|
-
// ── Session: pal [
|
|
40
|
+
// ── Session: pal [args] ──
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
42
|
+
interface ToolCheck {
|
|
43
|
+
name: string;
|
|
44
|
+
available: boolean;
|
|
45
|
+
version?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function checkTool(cmd: string, versionArgs: string[] = ["--version"]): ToolCheck {
|
|
49
|
+
try {
|
|
50
|
+
const result = spawnSync(cmd, versionArgs, {
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
shell: true,
|
|
53
|
+
timeout: 5000,
|
|
54
|
+
});
|
|
55
|
+
if (result.status === 0) {
|
|
56
|
+
const version = (result.stdout?.toString() || "").trim().split("\n")[0];
|
|
57
|
+
return { name: cmd, available: true, version };
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
// not found
|
|
61
|
+
}
|
|
62
|
+
return { name: cmd, available: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function detectAgent(): string | null {
|
|
66
|
+
if (checkTool("claude").available) return "claude";
|
|
67
|
+
if (checkTool("opencode").available) return "opencode";
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function session(sessionArgs: string[]) {
|
|
72
|
+
const agent = detectAgent();
|
|
73
|
+
if (!agent) {
|
|
74
|
+
log.error("No supported agent found. Install Claude Code or opencode.");
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const result = spawnSync(agent, sessionArgs, {
|
|
43
79
|
stdio: "inherit",
|
|
44
80
|
shell: true,
|
|
45
81
|
});
|
|
46
82
|
|
|
47
83
|
const exitCode = result.status ?? 1;
|
|
48
84
|
|
|
49
|
-
//
|
|
85
|
+
// Session summary (Claude only)
|
|
86
|
+
if (agent !== "claude") process.exit(exitCode);
|
|
50
87
|
try {
|
|
51
88
|
const projectsDir = resolve(homedir(), ".claude", "projects");
|
|
52
89
|
if (!existsSync(projectsDir)) process.exit(exitCode);
|
|
@@ -98,7 +135,7 @@ async function runCli(command: string | undefined, args: string[]) {
|
|
|
98
135
|
break;
|
|
99
136
|
case "install":
|
|
100
137
|
banner();
|
|
101
|
-
await install(
|
|
138
|
+
await install(resolveTargets(args));
|
|
102
139
|
break;
|
|
103
140
|
case "uninstall":
|
|
104
141
|
await uninstall(args);
|
|
@@ -112,6 +149,9 @@ async function runCli(command: string | undefined, args: string[]) {
|
|
|
112
149
|
case "status":
|
|
113
150
|
await status();
|
|
114
151
|
break;
|
|
152
|
+
case "doctor":
|
|
153
|
+
doctor();
|
|
154
|
+
break;
|
|
115
155
|
case "--help":
|
|
116
156
|
case "-h":
|
|
117
157
|
case "help":
|
|
@@ -148,6 +188,7 @@ function showHelp() {
|
|
|
148
188
|
pal cli export [path] [--dry-run] Export state to zip
|
|
149
189
|
pal cli import [path] [--dry-run] Import state from zip
|
|
150
190
|
pal cli status Show PAL configuration
|
|
191
|
+
pal cli doctor Check prerequisites and health
|
|
151
192
|
|
|
152
193
|
Environment:
|
|
153
194
|
PAL_HOME Override user state directory (default: ~/.pal or repo root)
|
|
@@ -176,6 +217,172 @@ function parseTargets(args: string[]): {
|
|
|
176
217
|
return { claude, opencode };
|
|
177
218
|
}
|
|
178
219
|
|
|
220
|
+
/** Resolve targets against available agents. Errors if explicitly requested but missing. */
|
|
221
|
+
function resolveTargets(
|
|
222
|
+
args: string[],
|
|
223
|
+
health?: DoctorResult
|
|
224
|
+
): { claude: boolean; opencode: boolean } {
|
|
225
|
+
const requested = parseTargets(args);
|
|
226
|
+
const h = health || doctor(true);
|
|
227
|
+
const explicit = args.some(
|
|
228
|
+
(a) => a === "--claude" || a === "--opencode" || a === "--all"
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
if (explicit) {
|
|
232
|
+
// User explicitly requested — error if not available
|
|
233
|
+
if (requested.claude && !h.claude.available) {
|
|
234
|
+
log.error("Claude Code is not installed. Run 'pal cli doctor' for details.");
|
|
235
|
+
process.exit(1);
|
|
236
|
+
}
|
|
237
|
+
if (requested.opencode && !h.opencode.available) {
|
|
238
|
+
log.error("opencode is not installed. Run 'pal cli doctor' for details.");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
return requested;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Default (no flags) — install for available agents only
|
|
245
|
+
const targets = {
|
|
246
|
+
claude: h.claude.available,
|
|
247
|
+
opencode: h.opencode.available,
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
if (!targets.claude) log.info("Skipping Claude Code (not installed)");
|
|
251
|
+
if (!targets.opencode) log.info("Skipping opencode (not installed)");
|
|
252
|
+
|
|
253
|
+
return targets;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// ── Hook health ──
|
|
257
|
+
|
|
258
|
+
interface HookHealth {
|
|
259
|
+
totalErrors: number;
|
|
260
|
+
lastError: string | null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function checkHookHealth(home: string): HookHealth {
|
|
264
|
+
const logPath = resolve(home, "memory", "state", "debug.log");
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
if (!existsSync(logPath)) return { totalErrors: 0, lastError: null };
|
|
268
|
+
|
|
269
|
+
const content = readFileSync(logPath, "utf-8");
|
|
270
|
+
const lines = content.split("\n").filter((l) => l.includes("] ERROR "));
|
|
271
|
+
|
|
272
|
+
// Filter to last 24h
|
|
273
|
+
const cutoff = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
|
274
|
+
const recentErrors = lines.filter((line) => {
|
|
275
|
+
const match = line.match(/^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]/);
|
|
276
|
+
if (!match) return false;
|
|
277
|
+
return new Date(match[1]) > cutoff;
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
const lastError =
|
|
281
|
+
recentErrors.length > 0
|
|
282
|
+
? recentErrors[recentErrors.length - 1]
|
|
283
|
+
.replace(/^\[.*?\] ERROR /, "")
|
|
284
|
+
.slice(0, 120)
|
|
285
|
+
: null;
|
|
286
|
+
|
|
287
|
+
return { totalErrors: recentErrors.length, lastError };
|
|
288
|
+
} catch {
|
|
289
|
+
return { totalErrors: 0, lastError: null };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ── Doctor ──
|
|
294
|
+
|
|
295
|
+
interface DoctorResult {
|
|
296
|
+
bun: ToolCheck;
|
|
297
|
+
claude: ToolCheck;
|
|
298
|
+
opencode: ToolCheck;
|
|
299
|
+
hasAgent: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function doctor(silent = false): DoctorResult {
|
|
303
|
+
// Allow CI/tests to skip agent detection
|
|
304
|
+
if (process.env.PAL_SKIP_DOCTOR === "1") {
|
|
305
|
+
return {
|
|
306
|
+
bun: { name: "bun", available: true, version: Bun.version },
|
|
307
|
+
claude: { name: "claude", available: true },
|
|
308
|
+
opencode: { name: "opencode", available: true },
|
|
309
|
+
hasAgent: true,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const bun = { name: "bun", available: true, version: Bun.version };
|
|
314
|
+
const claude = checkTool("claude");
|
|
315
|
+
const opencode = checkTool("opencode");
|
|
316
|
+
const hasAgent = claude.available || opencode.available;
|
|
317
|
+
|
|
318
|
+
const home = palHome();
|
|
319
|
+
const isRepo = existsSync(resolve(palPkg(), ".palroot"));
|
|
320
|
+
const telosCount = (() => {
|
|
321
|
+
try {
|
|
322
|
+
return readdirSync(resolve(home, "telos")).filter((f) => f.endsWith(".md")).length;
|
|
323
|
+
} catch {
|
|
324
|
+
return 0;
|
|
325
|
+
}
|
|
326
|
+
})();
|
|
327
|
+
|
|
328
|
+
if (!silent) {
|
|
329
|
+
const ok = (msg: string) => console.log(` \x1b[32m\u2713\x1b[0m ${msg}`);
|
|
330
|
+
const warn = (msg: string) => console.log(` \x1b[33m\u26A0\x1b[0m ${msg}`);
|
|
331
|
+
const fail = (msg: string) => console.log(` \x1b[31m\u2717\x1b[0m ${msg}`);
|
|
332
|
+
|
|
333
|
+
console.log("");
|
|
334
|
+
log.info("Doctor");
|
|
335
|
+
ok(`Bun ${bun.version}`);
|
|
336
|
+
claude.available
|
|
337
|
+
? ok(`Claude Code ${claude.version || ""}`.trim())
|
|
338
|
+
: fail("Claude Code — not found");
|
|
339
|
+
opencode.available
|
|
340
|
+
? ok(`opencode ${opencode.version || ""}`.trim())
|
|
341
|
+
: fail("opencode — not found");
|
|
342
|
+
ok(`PAL home: ${home} (${isRepo ? "repo" : "package"} mode)`);
|
|
343
|
+
telosCount > 0 ? ok(`TELOS: ${telosCount} files`) : fail("TELOS: not scaffolded");
|
|
344
|
+
|
|
345
|
+
// API key checks
|
|
346
|
+
process.env.ANTHROPIC_API_KEY
|
|
347
|
+
? ok("ANTHROPIC_API_KEY is set")
|
|
348
|
+
: fail("ANTHROPIC_API_KEY — not set (hooks need it for inference)");
|
|
349
|
+
process.env.GEMINI_API_KEY
|
|
350
|
+
? ok("GEMINI_API_KEY is set")
|
|
351
|
+
: warn("GEMINI_API_KEY — not set (optional, for YouTube analysis)");
|
|
352
|
+
|
|
353
|
+
// Hook health from debug.log
|
|
354
|
+
const hookHealth = checkHookHealth(home);
|
|
355
|
+
if (hookHealth.totalErrors === 0) {
|
|
356
|
+
ok("Hooks: no recent errors");
|
|
357
|
+
} else {
|
|
358
|
+
fail(`Hooks: ${hookHealth.totalErrors} error(s) in last 24h`);
|
|
359
|
+
if (hookHealth.lastError) {
|
|
360
|
+
log.warn(` Last: ${hookHealth.lastError}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Pending tag suggestions
|
|
365
|
+
const pending = getPendingSuggestions();
|
|
366
|
+
const pendingEntries = Object.entries(pending).sort((a, b) => b[1] - a[1]);
|
|
367
|
+
if (pendingEntries.length > 0) {
|
|
368
|
+
warn(`Tags: ${pendingEntries.length} pending suggestion(s)`);
|
|
369
|
+
for (const [tag, count] of pendingEntries.slice(0, 5)) {
|
|
370
|
+
log.info(` "${tag}" (${count}/3 to promote)`);
|
|
371
|
+
}
|
|
372
|
+
} else {
|
|
373
|
+
ok("Tags: no pending suggestions");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!hasAgent) {
|
|
377
|
+
console.log("");
|
|
378
|
+
log.error("No supported agent found. Install Claude Code or opencode.");
|
|
379
|
+
}
|
|
380
|
+
console.log("");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return { bun, claude, opencode, hasAgent };
|
|
384
|
+
}
|
|
385
|
+
|
|
179
386
|
// ── Commands ──
|
|
180
387
|
|
|
181
388
|
async function init(args: string[]) {
|
|
@@ -184,6 +391,12 @@ async function init(args: string[]) {
|
|
|
184
391
|
|
|
185
392
|
banner();
|
|
186
393
|
|
|
394
|
+
// Run doctor first — abort if no agents available
|
|
395
|
+
const health = doctor(false);
|
|
396
|
+
if (!health.hasAgent) {
|
|
397
|
+
process.exit(1);
|
|
398
|
+
}
|
|
399
|
+
|
|
187
400
|
const home = palHome();
|
|
188
401
|
const isRepo = existsSync(resolve(palPkg(), ".palroot"));
|
|
189
402
|
|
|
@@ -196,7 +409,9 @@ async function init(args: string[]) {
|
|
|
196
409
|
scaffoldTelos();
|
|
197
410
|
ensureSetupState();
|
|
198
411
|
|
|
199
|
-
|
|
412
|
+
// Auto-detect available targets
|
|
413
|
+
const targets = resolveTargets(args, health);
|
|
414
|
+
await install(targets);
|
|
200
415
|
|
|
201
416
|
console.log("");
|
|
202
417
|
const state = ensureSetupState();
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Transcript is read from the file at transcript_path, NOT from stdin.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { checkReadmeSync } from "./handlers/readme-sync";
|
|
9
10
|
import { logError } from "./lib/log";
|
|
10
11
|
import { readStdinJSON } from "./lib/stdin";
|
|
11
12
|
import { runStopHandlers } from "./lib/stop";
|
|
@@ -17,6 +18,17 @@ interface StopHookInput {
|
|
|
17
18
|
last_assistant_message?: string;
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
// Check README sync before anything else — may block the session
|
|
22
|
+
try {
|
|
23
|
+
const decision = checkReadmeSync();
|
|
24
|
+
if (decision.decision === "block") {
|
|
25
|
+
console.log(JSON.stringify(decision));
|
|
26
|
+
process.exit(0);
|
|
27
|
+
}
|
|
28
|
+
} catch (err) {
|
|
29
|
+
logError("StopOrchestrator:readme-sync", err);
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
const input = await readStdinJSON<StopHookInput>();
|
|
21
33
|
if (!input?.transcript_path) {
|
|
22
34
|
logError("StopOrchestrator", "No transcript_path in hook input");
|
|
@@ -2,14 +2,16 @@
|
|
|
2
2
|
* Deep Failure Capture — full context dump for ratings 1–3.
|
|
3
3
|
*
|
|
4
4
|
* Writes to memory/learning/failures/YYYY-MM/{timestamp}_{slug}/
|
|
5
|
-
*
|
|
6
|
-
* sentiment.json —
|
|
5
|
+
* capture.md — frontmatter metadata + failure context body
|
|
6
|
+
* sentiment.json — DEPRECATED legacy format (kept for backward compat)
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { writeFileSync } from "node:fs";
|
|
10
10
|
import { resolve } from "node:path";
|
|
11
|
+
import { stringify } from "../lib/frontmatter";
|
|
11
12
|
import { inference } from "../lib/inference";
|
|
12
13
|
import { ensureDir, paths } from "../lib/paths";
|
|
14
|
+
import { getVocabulary, recordSuggestedTag } from "../lib/tags";
|
|
13
15
|
import { fileTimestamp, monthPath } from "../lib/time";
|
|
14
16
|
import { logTokenUsage } from "../lib/token-usage";
|
|
15
17
|
import {
|
|
@@ -53,32 +55,38 @@ export async function captureFailure(
|
|
|
53
55
|
resolve(paths.failures(), monthPath(), `${fileTimestamp()}_${slug}`)
|
|
54
56
|
);
|
|
55
57
|
|
|
56
|
-
// Attempt inference to fill root cause analysis
|
|
58
|
+
// Attempt inference to fill root cause analysis + tags
|
|
57
59
|
let whatWentWrong = "";
|
|
58
60
|
let whatToDoDifferently = "";
|
|
61
|
+
let tags: string[] = [];
|
|
59
62
|
try {
|
|
63
|
+
const vocab = getVocabulary();
|
|
60
64
|
const analysisResult = await inference({
|
|
61
|
-
system:
|
|
62
|
-
"You are analyzing a failed AI assistant interaction. Based on the context, identify what went wrong and what should be done differently. Be specific and actionable.",
|
|
65
|
+
system: `You are analyzing a failed AI assistant interaction. Based on the context, identify what went wrong and what should be done differently. Be specific and actionable. Also pick 1-3 tags from this list: [${vocab.join(", ")}]. If none fit, leave tags empty and put your suggested tag in suggested_tag.`,
|
|
63
66
|
user: [
|
|
64
67
|
`Rating: ${rating}/10`,
|
|
65
68
|
`Context: ${context}`,
|
|
66
69
|
detailedContext ? `Analysis: ${detailedContext}` : "",
|
|
67
|
-
`
|
|
68
|
-
`
|
|
70
|
+
`Assistant response (what the user reacted to): ${lastAssistant}`,
|
|
71
|
+
`User reaction (the frustrated message): ${lastUser}`,
|
|
69
72
|
]
|
|
70
73
|
.filter(Boolean)
|
|
71
74
|
.join("\n"),
|
|
72
|
-
maxTokens:
|
|
73
|
-
timeout:
|
|
75
|
+
maxTokens: 400,
|
|
76
|
+
timeout: 15000,
|
|
74
77
|
jsonSchema: {
|
|
75
78
|
type: "object" as const,
|
|
76
79
|
additionalProperties: false,
|
|
77
80
|
properties: {
|
|
78
81
|
what_went_wrong: { type: "string" as const },
|
|
79
82
|
what_to_do_differently: { type: "string" as const },
|
|
83
|
+
tags: {
|
|
84
|
+
type: "array" as const,
|
|
85
|
+
items: { type: "string" as const },
|
|
86
|
+
},
|
|
87
|
+
suggested_tag: { type: "string" as const },
|
|
80
88
|
},
|
|
81
|
-
required: ["what_went_wrong", "what_to_do_differently"],
|
|
89
|
+
required: ["what_went_wrong", "what_to_do_differently", "tags"],
|
|
82
90
|
},
|
|
83
91
|
});
|
|
84
92
|
if (analysisResult.usage) logTokenUsage("failure", analysisResult.usage);
|
|
@@ -86,51 +94,48 @@ export async function captureFailure(
|
|
|
86
94
|
const parsed = JSON.parse(analysisResult.output) as {
|
|
87
95
|
what_went_wrong?: string;
|
|
88
96
|
what_to_do_differently?: string;
|
|
97
|
+
tags?: string[];
|
|
98
|
+
suggested_tag?: string;
|
|
89
99
|
};
|
|
90
100
|
whatWentWrong = parsed.what_went_wrong ?? "";
|
|
91
101
|
whatToDoDifferently = parsed.what_to_do_differently ?? "";
|
|
102
|
+
if (parsed.tags?.length) tags = parsed.tags;
|
|
103
|
+
if (parsed.suggested_tag) recordSuggestedTag(parsed.suggested_tag);
|
|
92
104
|
}
|
|
93
105
|
} catch {
|
|
94
106
|
// Graceful fallback — empty sections are still useful with the other context
|
|
95
107
|
}
|
|
96
108
|
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
"## Last User Message",
|
|
106
|
-
lastUser || "*(unavailable)*",
|
|
107
|
-
"",
|
|
108
|
-
"## Last Assistant Response",
|
|
109
|
-
lastAssistant || "*(unavailable)*",
|
|
110
|
-
"",
|
|
111
|
-
...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
|
|
112
|
-
"## What Went Wrong?",
|
|
113
|
-
whatWentWrong || "",
|
|
114
|
-
"",
|
|
115
|
-
"## What Should Be Done Differently?",
|
|
116
|
-
whatToDoDifferently || "",
|
|
117
|
-
"",
|
|
118
|
-
].join("\n"),
|
|
119
|
-
"utf-8"
|
|
120
|
-
);
|
|
109
|
+
const meta: Record<string, unknown> = {
|
|
110
|
+
rating,
|
|
111
|
+
context,
|
|
112
|
+
date: new Date().toISOString().slice(0, 10),
|
|
113
|
+
ts: new Date().toISOString(),
|
|
114
|
+
slug,
|
|
115
|
+
};
|
|
116
|
+
if (tags.length > 0) meta.tags = tags;
|
|
121
117
|
|
|
118
|
+
const body = [
|
|
119
|
+
"## Last User Message",
|
|
120
|
+
lastUser || "*(unavailable)*",
|
|
121
|
+
"",
|
|
122
|
+
"## Last Assistant Response",
|
|
123
|
+
lastAssistant || "*(unavailable)*",
|
|
124
|
+
"",
|
|
125
|
+
...(detailedContext ? ["## AI Response Context", detailedContext, ""] : []),
|
|
126
|
+
"## What Went Wrong?",
|
|
127
|
+
whatWentWrong || "",
|
|
128
|
+
"",
|
|
129
|
+
"## What Should Be Done Differently?",
|
|
130
|
+
whatToDoDifferently || "",
|
|
131
|
+
].join("\n");
|
|
132
|
+
|
|
133
|
+
writeFileSync(resolve(dir, "capture.md"), stringify(meta, body), "utf-8");
|
|
134
|
+
|
|
135
|
+
// DEPRECATED: legacy sentiment.json — remove once all readers use capture.md frontmatter
|
|
122
136
|
writeFileSync(
|
|
123
137
|
resolve(dir, "sentiment.json"),
|
|
124
|
-
JSON.stringify(
|
|
125
|
-
{
|
|
126
|
-
rating,
|
|
127
|
-
context,
|
|
128
|
-
ts: new Date().toISOString(),
|
|
129
|
-
slug,
|
|
130
|
-
},
|
|
131
|
-
null,
|
|
132
|
-
2
|
|
133
|
-
),
|
|
138
|
+
JSON.stringify({ rating, context, ts: new Date().toISOString(), slug }, null, 2),
|
|
134
139
|
"utf-8"
|
|
135
140
|
);
|
|
136
141
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
+
import { stringify } from "../lib/frontmatter";
|
|
13
14
|
import { inference } from "../lib/inference";
|
|
14
15
|
import { categorizeLearning } from "../lib/learning-category";
|
|
15
16
|
import { ensureDir, paths } from "../lib/paths";
|
|
@@ -244,24 +245,24 @@ function writeLearningMarkdown(
|
|
|
244
245
|
const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
|
|
245
246
|
const filename = `${fileTimestamp()}_${source}-rating-${rating}_${category}.md`;
|
|
246
247
|
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
248
|
+
const meta: Record<string, unknown> = {
|
|
249
|
+
title: context.slice(0, 100) || "(low rating)",
|
|
250
|
+
category,
|
|
251
|
+
date: new Date().toISOString().slice(0, 10),
|
|
252
|
+
rating,
|
|
253
|
+
source,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const body = [
|
|
255
257
|
"## Context",
|
|
256
258
|
context || "*(unavailable)*",
|
|
257
259
|
"",
|
|
258
260
|
...(detailedContext ? ["## Analysis", detailedContext, ""] : []),
|
|
259
261
|
"## Last Response",
|
|
260
262
|
responsePreview || "*(unavailable)*",
|
|
261
|
-
"",
|
|
262
263
|
].join("\n");
|
|
263
264
|
|
|
264
|
-
writeFileSync(resolve(dir, filename),
|
|
265
|
+
writeFileSync(resolve(dir, filename), stringify(meta, body), "utf-8");
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
function handleRating(
|
|
@@ -295,14 +296,7 @@ function handleRating(
|
|
|
295
296
|
),
|
|
296
297
|
"utf-8"
|
|
297
298
|
);
|
|
298
|
-
//
|
|
299
|
-
writeLearningMarkdown(
|
|
300
|
-
rating,
|
|
301
|
-
source,
|
|
302
|
-
context,
|
|
303
|
-
detailedContext ?? "",
|
|
304
|
-
responsePreview
|
|
305
|
-
);
|
|
299
|
+
// No learning markdown for ≤3 — failure capture covers it with richer analysis + tags
|
|
306
300
|
} else if (rating < 5) {
|
|
307
301
|
// Low but not critical — write learning markdown
|
|
308
302
|
writeLearningMarkdown(
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stop handler: check if README.md is out of sync with code.
|
|
3
|
+
*
|
|
4
|
+
* Runs git diff to see if documentable files changed in this session.
|
|
5
|
+
* If they did and README is stale, returns a block decision.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execSync } from "node:child_process";
|
|
9
|
+
import { logDebug } from "../lib/log";
|
|
10
|
+
import { palPkg } from "../lib/paths";
|
|
11
|
+
import { validateReadmeSync, WATCHED_PATHS } from "../lib/readme-sync";
|
|
12
|
+
|
|
13
|
+
/** Check if any watched files have uncommitted changes. */
|
|
14
|
+
function hasDocumentableChanges(): boolean {
|
|
15
|
+
try {
|
|
16
|
+
const diff = execSync("git diff --name-only HEAD", {
|
|
17
|
+
cwd: palPkg(),
|
|
18
|
+
encoding: "utf-8",
|
|
19
|
+
}).trim();
|
|
20
|
+
|
|
21
|
+
const staged = execSync("git diff --name-only --cached", {
|
|
22
|
+
cwd: palPkg(),
|
|
23
|
+
encoding: "utf-8",
|
|
24
|
+
}).trim();
|
|
25
|
+
|
|
26
|
+
const changed = `${diff}\n${staged}`.split("\n").filter((f) => f.length > 0);
|
|
27
|
+
|
|
28
|
+
return changed.some((file) =>
|
|
29
|
+
WATCHED_PATHS.some((watched) => file === watched || file.startsWith(`${watched}/`))
|
|
30
|
+
);
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ReadmeSyncDecision {
|
|
37
|
+
decision?: "block";
|
|
38
|
+
reason?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Returns a block decision if README is stale, or empty object to allow stop. */
|
|
42
|
+
export function checkReadmeSync(): ReadmeSyncDecision {
|
|
43
|
+
if (!hasDocumentableChanges()) {
|
|
44
|
+
logDebug("readme-sync", "No documentable changes detected");
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
logDebug("readme-sync", "Documentable files changed — validating README");
|
|
49
|
+
const result = validateReadmeSync();
|
|
50
|
+
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
logDebug("readme-sync", `README out of sync: ${result.issues.join("; ")}`);
|
|
53
|
+
return {
|
|
54
|
+
decision: "block",
|
|
55
|
+
reason: `README.md is out of date. Please update it before finishing:\n${result.issues.map((i) => `- ${i}`).join("\n")}`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
logDebug("readme-sync", "README is in sync");
|
|
60
|
+
return {};
|
|
61
|
+
}
|