portable-agent-layer 0.23.0 → 0.24.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.
@@ -216,7 +216,26 @@ Only add threads that genuinely need follow-up. Resolve existing threads if this
216
216
  bun ~/.pal/tools/thread.ts --resolve --id <id>
217
217
  ```
218
218
 
219
- **4. Wisdom Frame** (Extended+ only) if the session produced a genuine, reusable insight:
219
+ **4. Opinion capture** — scan the conversation for moments where the user:
220
+ - Confirmed something you did: "yes exactly", "keep doing that", "10 rated", accepted without pushback
221
+ - Corrected something you did: "no", "don't do that", "stop", "that's not what I meant"
222
+ - Revealed a preference by repeating a pattern (asked for concise answers twice, always checked PAI first, etc.)
223
+
224
+ For each, invoke the opinion tool:
225
+ ```bash
226
+ # User confirmed a preference
227
+ bun ~/.pal/skills/opinion/tools/opinion.ts evidence "matching keywords" --confirmation "what they confirmed"
228
+
229
+ # User corrected a preference
230
+ bun ~/.pal/skills/opinion/tools/opinion.ts evidence "matching keywords" --contradiction "what they corrected"
231
+
232
+ # New pattern observed (no existing opinion matches)
233
+ bun ~/.pal/skills/opinion/tools/opinion.ts add "the preference" --category communication|technical|workflow|general
234
+ ```
235
+
236
+ Skip if nothing in the conversation touched preferences or working style.
237
+
238
+ **5. Wisdom Frame** (Extended+ only) — if the session produced a genuine, reusable insight:
220
239
 
221
240
  ```bash
222
241
  bun ~/.pal/tools/wisdom-frame.ts --domain <domain> --observation "insight" [--type principle|contextual-rule|anti-pattern|evolution]
@@ -30,6 +30,7 @@
30
30
  "activeWork": true,
31
31
  "projectHistory": true,
32
32
  "sessionIntelligence": true,
33
- "handoff": true
33
+ "handoff": true,
34
+ "selfModel": true
34
35
  }
35
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portable-agent-layer",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "PAL — Portable Agent Layer: persistent personal context for AI coding assistants",
5
5
  "type": "module",
6
6
  "bin": {
@@ -3,43 +3,19 @@
3
3
  * Called during `pal install`. Skips fields that already have values.
4
4
  */
5
5
 
6
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
7
- import { resolve } from "node:path";
8
6
  import * as clack from "@clack/prompts";
9
- import { palHome } from "../hooks/lib/paths";
10
-
11
- interface PalSettings {
12
- identity?: {
13
- ai?: { name?: string; fullName?: string; displayName?: string; catchphrase?: string };
14
- principal?: { name?: string; timezone?: string };
15
- };
16
- [key: string]: unknown;
17
- }
18
-
19
- function settingsPath(): string {
20
- return resolve(palHome(), "memory", "pal-settings.json");
21
- }
22
-
23
- function readSettings(): PalSettings {
24
- const p = settingsPath();
25
- if (!existsSync(p)) return {};
26
- try {
27
- return JSON.parse(readFileSync(p, "utf-8"));
28
- } catch {
29
- return {};
30
- }
31
- }
32
-
33
- function writeSettings(settings: PalSettings): void {
34
- writeFileSync(settingsPath(), `${JSON.stringify(settings, null, 2)}\n`, "utf-8");
35
- }
7
+ import {
8
+ type PalSettingsData,
9
+ raw as readSettings,
10
+ write as writeSettings,
11
+ } from "../hooks/lib/settings";
36
12
 
37
13
  /** Prompt for missing identity fields. Skips any field that already has a value. */
38
14
  export async function promptIdentity(): Promise<void> {
39
15
  // Skip interactive prompts in non-TTY environments (tests, CI)
40
16
  if (!process.stdin.isTTY) return;
41
17
 
42
- const settings = readSettings();
18
+ const settings: PalSettingsData = { ...readSettings() };
43
19
  if (!settings.identity) settings.identity = {};
44
20
  if (!settings.identity.ai) settings.identity.ai = {};
45
21
  if (!settings.identity.principal) settings.identity.principal = {};
@@ -30,7 +30,8 @@ export async function captureFailure(
30
30
  rating: number,
31
31
  context: string,
32
32
  transcript: string,
33
- detailedContext?: string
33
+ detailedContext?: string,
34
+ principle?: string
34
35
  ): Promise<void> {
35
36
  const messages = parseMessages(transcript);
36
37
 
@@ -55,6 +56,7 @@ export async function captureFailure(
55
56
  ts: new Date().toISOString(),
56
57
  slug,
57
58
  };
59
+ if (principle) meta.principle = principle;
58
60
 
59
61
  const body = [
60
62
  "## What Happened",
@@ -166,8 +166,16 @@ const SENTIMENT_SCHEMA = {
166
166
  confidence: { type: "number" },
167
167
  summary: { type: "string" },
168
168
  detailed_context: { type: "string" },
169
+ principle: { type: "string" },
169
170
  },
170
- required: ["rating", "sentiment", "confidence", "summary", "detailed_context"],
171
+ required: [
172
+ "rating",
173
+ "sentiment",
174
+ "confidence",
175
+ "summary",
176
+ "detailed_context",
177
+ "principle",
178
+ ],
171
179
  additionalProperties: false,
172
180
  } as const;
173
181
 
@@ -177,6 +185,7 @@ interface SentimentResult {
177
185
  confidence: number;
178
186
  summary: string;
179
187
  detailed_context: string;
188
+ principle: string;
180
189
  }
181
190
 
182
191
  const SENTIMENT_SYSTEM_PROMPT = `Analyze the user's message for emotional sentiment toward the AI assistant.
@@ -187,7 +196,8 @@ OUTPUT FORMAT (JSON only):
187
196
  "sentiment": "positive" | "negative" | "neutral",
188
197
  "confidence": <0.0-1.0>,
189
198
  "summary": "<brief explanation, 10 words max>",
190
- "detailed_context": "<comprehensive analysis, 50-150 words>"
199
+ "detailed_context": "<comprehensive analysis, 50-150 words>",
200
+ "principle": "<one actionable rule the AI should follow to avoid this failure or repeat this success, 10-20 words. Start with a verb: 'Verify...', 'Always...', 'Never...', 'Ask before...'>"
191
201
  }
192
202
 
193
203
  DETAILED_CONTEXT REQUIREMENTS:
@@ -240,6 +250,7 @@ function handleRating(
240
250
  context: string,
241
251
  source: string,
242
252
  detailedContext?: string,
253
+ principle?: string,
243
254
  sessionId?: string,
244
255
  userMessage?: string
245
256
  ): void {
@@ -257,6 +268,7 @@ function handleRating(
257
268
  context,
258
269
  source,
259
270
  detailedContext,
271
+ principle,
260
272
  responsePreview,
261
273
  userPreview,
262
274
  ts: now(),
@@ -284,6 +296,7 @@ async function handleImplicitSentiment(
284
296
  `Direct praise: "${trimmed}"`,
285
297
  "implicit",
286
298
  undefined,
299
+ undefined,
287
300
  sessionId,
288
301
  trimmed
289
302
  );
@@ -328,6 +341,7 @@ async function handleImplicitSentiment(
328
341
  `${parsed.summary}: ${trimmed.slice(0, 200)}`,
329
342
  "implicit",
330
343
  parsed.detailed_context,
344
+ parsed.principle,
331
345
  sessionId,
332
346
  trimmed
333
347
  );
@@ -352,6 +366,7 @@ export async function captureRating(message: string, sessionId?: string): Promis
352
366
  explicit.comment || cleaned.slice(0, 200),
353
367
  "explicit",
354
368
  undefined,
369
+ undefined,
355
370
  sessionId,
356
371
  cleaned
357
372
  );
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Auto-trigger for relationship reflect — runs when conditions are met:
3
- * - 7+ days since last reflect
4
- * - 10+ new relationship notes since last reflect
3
+ * - 1+ days since last reflect
4
+ * - 5+ new relationship notes since last reflect
5
5
  *
6
6
  * Spawns `bun run tool:reflect` as a detached background process.
7
7
  */
@@ -12,8 +12,8 @@ import { logDebug } from "../lib/log";
12
12
  import { getLastReflectDate } from "../lib/opinions";
13
13
  import { palPkg, paths } from "../lib/paths";
14
14
 
15
- const MIN_DAYS_BETWEEN = 7;
16
- const MIN_NEW_NOTES = 10;
15
+ const MIN_DAYS_BETWEEN = 1;
16
+ const MIN_NEW_NOTES = 5;
17
17
 
18
18
  function countNotesSince(since: string): number {
19
19
  const relDir = paths.relationship();
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Auto-trigger for self-model synthesis — runs daily.
3
+ * writeSelfModel has a 24h TTL guard, so this is safe to call every session.
4
+ * Respects dynamicContext.selfModel — if disabled, skips generation entirely.
5
+ */
6
+
7
+ import { writeSelfModel } from "../../tools/self-model";
8
+ import { logDebug } from "../lib/log";
9
+ import { isEnabled } from "../lib/settings";
10
+
11
+ export async function checkSelfModelTrigger(): Promise<void> {
12
+ if (!isEnabled("selfModel")) {
13
+ logDebug("self-model-trigger", "Disabled in pal-settings.json");
14
+ return;
15
+ }
16
+
17
+ try {
18
+ const result = await writeSelfModel(30);
19
+ if (result.skipped) {
20
+ logDebug("self-model-trigger", "Skipped — last synthesis < 24h ago");
21
+ } else {
22
+ logDebug("self-model-trigger", `Self-model written: ${result.path}`);
23
+ }
24
+ } catch {
25
+ // Non-critical — self-model is best-effort
26
+ }
27
+ }
@@ -18,7 +18,7 @@ import {
18
18
  writeFileSync,
19
19
  } from "node:fs";
20
20
  import { dirname, relative, resolve } from "node:path";
21
- import { assets, ensureDir, palHome, paths, platform } from "./paths";
21
+ import { assets, ensureDir, paths, platform } from "./paths";
22
22
  import { buildSetupPrompt, readSetupState } from "./setup";
23
23
 
24
24
  const TEMPLATE_PATH = assets.agentsMdTemplate();
@@ -89,7 +89,7 @@ export function needsRebuild(): boolean {
89
89
  const sources: string[] = [
90
90
  TEMPLATE_PATH,
91
91
  resolve(paths.state(), "setup.json"),
92
- palSettingsPath(),
92
+ resolve(paths.memory(), "pal-settings.json"),
93
93
  ];
94
94
 
95
95
  // Track PAL doc sources for rebuild detection
@@ -103,46 +103,7 @@ export function needsRebuild(): boolean {
103
103
  return latestMtime(...sources) > outputMtime;
104
104
  }
105
105
 
106
- interface Identity {
107
- ai: { name: string; displayName: string; catchphrase: string };
108
- principal: { name: string };
109
- }
110
-
111
- const IDENTITY_DEFAULTS: Identity = {
112
- ai: { name: "Assistant", displayName: "ASSISTANT", catchphrase: "" },
113
- principal: { name: "" },
114
- };
115
-
116
- function palSettingsPath(): string {
117
- return resolve(palHome(), "memory", "pal-settings.json");
118
- }
119
-
120
- /** Load identity from pal-settings.json */
121
- export function loadIdentity(): Identity {
122
- const p = palSettingsPath();
123
- if (!existsSync(p)) return IDENTITY_DEFAULTS;
124
-
125
- try {
126
- const data = JSON.parse(readFileSync(p, "utf-8"));
127
- const ai = data.identity?.ai ?? {};
128
- const principal = data.identity?.principal ?? {};
129
- const name = ai.name || IDENTITY_DEFAULTS.ai.name;
130
- const catchphrase = (ai.catchphrase || "").replace("{name}", name);
131
-
132
- return {
133
- ai: {
134
- name,
135
- displayName: ai.displayName || IDENTITY_DEFAULTS.ai.displayName,
136
- catchphrase,
137
- },
138
- principal: {
139
- name: principal.name || IDENTITY_DEFAULTS.principal.name,
140
- },
141
- };
142
- } catch {
143
- return IDENTITY_DEFAULTS;
144
- }
145
- }
106
+ import { identity } from "./settings";
146
107
 
147
108
  /** Render AGENTS.md from the template using current state */
148
109
  export function buildClaudeMd(): string {
@@ -152,14 +113,14 @@ export function buildClaudeMd(): string {
152
113
 
153
114
  const state = readSetupState();
154
115
  const setupPrompt = state ? buildSetupPrompt(state) : null;
155
- const identity = loadIdentity();
116
+ const id = identity();
156
117
 
157
118
  return template
158
119
  .replace("{{SETUP_PROMPT}}", setupPrompt ? `${setupPrompt}\n` : "")
159
- .replaceAll("{{IDENTITY_NAME}}", identity.ai.name)
160
- .replaceAll("{{IDENTITY_DISPLAY}}", identity.ai.displayName)
161
- .replaceAll("{{IDENTITY_CATCHPHRASE}}", identity.ai.catchphrase)
162
- .replaceAll("{{PRINCIPAL_NAME}}", identity.principal.name);
120
+ .replaceAll("{{IDENTITY_NAME}}", id.ai.name)
121
+ .replaceAll("{{IDENTITY_DISPLAY}}", id.ai.displayName)
122
+ .replaceAll("{{IDENTITY_CATCHPHRASE}}", id.ai.catchphrase)
123
+ .replaceAll("{{PRINCIPAL_NAME}}", id.principal.name);
163
124
  }
164
125
 
165
126
  /** Regenerate AGENTS.md if any source file is newer, and ensure CLAUDE.md symlink exists. Returns true if rebuilt. */
@@ -12,36 +12,16 @@ import { loadOpinionContext } from "./opinions";
12
12
  import { paths } from "./paths";
13
13
  import { loadRecentNotes } from "./relationship";
14
14
  import { readSessionNames } from "./session-names";
15
+ import * as settings from "./settings";
15
16
  import { buildSetupPrompt, readSetupState, remainingSteps, STEP_ORDER } from "./setup";
16
17
  import { computeSignalTrends, formatTrends } from "./signal-trends";
17
18
  import { readFramePrinciples } from "./wisdom";
18
19
  import { readProjectHistory, readSessions, recentSessions } from "./work-tracking";
19
20
 
20
- interface PalSettings {
21
- loadAtStartup?: { files?: string[] };
22
- dynamicContext?: Record<string, boolean>;
23
- }
24
-
25
- /** Load pal-settings.json from memory/ */
26
- function loadPalSettings(): PalSettings {
27
- const p = resolve(paths.memory(), "pal-settings.json");
28
- if (!existsSync(p)) return {};
29
- try {
30
- return JSON.parse(readFileSync(p, "utf-8"));
31
- } catch {
32
- return {};
33
- }
34
- }
35
-
36
- /** Check if a dynamic context section is enabled (defaults to true) */
37
- function isEnabled(settings: PalSettings, key: string): boolean {
38
- return settings.dynamicContext?.[key] !== false;
39
- }
40
-
41
21
  /** Load and concatenate loadAtStartup files */
42
- function loadStartupFiles(settings: PalSettings): string {
43
- const files = settings.loadAtStartup?.files;
44
- if (!files || files.length === 0) return "";
22
+ function loadStartupFiles(): string {
23
+ const files = settings.startupFiles();
24
+ if (files.length === 0) return "";
45
25
 
46
26
  const home = homedir();
47
27
  const sections: string[] = [];
@@ -223,6 +203,19 @@ export function loadLearningDigest(): string {
223
203
  }
224
204
  }
225
205
 
206
+ /** Load self-model for session context injection */
207
+ export function loadSelfModel(): string {
208
+ try {
209
+ const p = resolve(paths.memory(), "self-model", "current.md");
210
+ if (!existsSync(p)) return "";
211
+ const content = readFileSync(p, "utf-8").trim();
212
+ if (!content) return "";
213
+ return content;
214
+ } catch {
215
+ return "";
216
+ }
217
+ }
218
+
226
219
  /** Load 5 most recent failure contexts as an "avoid" list */
227
220
  export function loadFailurePatterns(): string {
228
221
  try {
@@ -231,10 +224,11 @@ export function loadFailurePatterns(): string {
231
224
 
232
225
  const lines = entries.map((e) => {
233
226
  const label = e.rating ? `[${e.rating}/10]` : "";
234
- return `- ${label} ${e.context}`.trim();
227
+ const text = e.principle || e.context;
228
+ return `- ${label} ${text}`.trim();
235
229
  });
236
230
 
237
- return ["## Recent Failure Patterns (Avoid)", ...lines].join("\n");
231
+ return ["## Lessons from Recent Failures Apply These Now", ...lines].join("\n");
238
232
  } catch {
239
233
  return "";
240
234
  }
@@ -451,30 +445,29 @@ export function loadHandoff(): string {
451
445
  * things that change per-session and can't live in a static file.
452
446
  */
453
447
  export function buildSystemReminder(): string {
454
- const settings = loadPalSettings();
455
- const startup = loadStartupFiles(settings);
456
- const work = isEnabled(settings, "activeWork") ? loadActiveWork() : null;
457
- const wisdom = isEnabled(settings, "wisdom") ? loadWisdomContext() : "";
458
- const relationship = isEnabled(settings, "relationship")
448
+ const startup = loadStartupFiles();
449
+ const work = settings.isEnabled("activeWork") ? loadActiveWork() : null;
450
+ const wisdom = settings.isEnabled("wisdom") ? loadWisdomContext() : "";
451
+ const relationship = settings.isEnabled("relationship")
459
452
  ? loadRelationshipContext()
460
453
  : "";
461
- const digest = isEnabled(settings, "learningDigest") ? loadLearningDigest() : "";
462
- const projectHistory = isEnabled(settings, "projectHistory")
454
+ const digest = settings.isEnabled("learningDigest") ? loadLearningDigest() : "";
455
+ const projectHistory = settings.isEnabled("projectHistory")
463
456
  ? loadProjectHistoryContext()
464
457
  : "";
465
- const trends = isEnabled(settings, "signalTrends") ? loadSignalTrends() : "";
466
- const failures = isEnabled(settings, "failurePatterns") ? loadFailurePatterns() : "";
467
- const synthesis = isEnabled(settings, "synthesis")
468
- ? loadSynthesisRecommendations()
469
- : "";
470
- const opinions = isEnabled(settings, "opinions") ? loadOpinionContext() : "";
471
- const intelligence = isEnabled(settings, "sessionIntelligence")
458
+ const trends = settings.isEnabled("signalTrends") ? loadSignalTrends() : "";
459
+ const failures = settings.isEnabled("failurePatterns") ? loadFailurePatterns() : "";
460
+ const synthesis = settings.isEnabled("synthesis") ? loadSynthesisRecommendations() : "";
461
+ const opinions = settings.isEnabled("opinions") ? loadOpinionContext() : "";
462
+ const selfModel = settings.isEnabled("selfModel") ? loadSelfModel() : "";
463
+ const intelligence = settings.isEnabled("sessionIntelligence")
472
464
  ? loadSessionIntelligence()
473
465
  : "";
474
- const handoff = isEnabled(settings, "handoff") ? loadHandoff() : "";
466
+ const handoff = settings.isEnabled("handoff") ? loadHandoff() : "";
475
467
  const parts: string[] = [];
476
468
  if (startup) parts.push(startup);
477
469
  if (handoff) parts.push(handoff);
470
+ if (selfModel) parts.push(selfModel);
478
471
  if (wisdom) parts.push(wisdom);
479
472
  if (opinions) parts.push(opinions);
480
473
  if (intelligence) parts.push(intelligence);
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  export const HAIKU_MODEL = "claude-haiku-4-5-20251001";
6
+ export const SONNET_MODEL = "claude-sonnet-4-6";
6
7
 
7
8
  /** Pricing per million tokens (USD) — from https://platform.claude.com/docs/en/about-claude/pricing */
8
9
  export const MODEL_PRICING: Record<
@@ -0,0 +1,112 @@
1
+ /**
2
+ * PalSettings — single source of truth for pal-settings.json.
3
+ *
4
+ * Reads once, caches in memory for the process lifetime.
5
+ * All consumers import from here instead of reading the file directly.
6
+ */
7
+
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import { paths } from "./paths";
11
+
12
+ // ── Types ──
13
+
14
+ export interface Identity {
15
+ ai: { name: string; fullName: string; displayName: string; catchphrase: string };
16
+ principal: { name: string; timezone: string };
17
+ }
18
+
19
+ export interface PalSettingsData {
20
+ identity?: {
21
+ ai?: { name?: string; fullName?: string; displayName?: string; catchphrase?: string };
22
+ principal?: { name?: string; timezone?: string };
23
+ };
24
+ loadAtStartup?: { files?: string[] };
25
+ dynamicContext?: Record<string, boolean>;
26
+ [key: string]: unknown;
27
+ }
28
+
29
+ const IDENTITY_DEFAULTS: Identity = {
30
+ ai: {
31
+ name: "Assistant",
32
+ fullName: "AI Assistant",
33
+ displayName: "ASSISTANT",
34
+ catchphrase: "",
35
+ },
36
+ principal: { name: "User", timezone: "" },
37
+ };
38
+
39
+ // ── Singleton ──
40
+
41
+ let cached: PalSettingsData | null = null;
42
+
43
+ function settingsPath(): string {
44
+ return resolve(paths.memory(), "pal-settings.json");
45
+ }
46
+
47
+ function load(): PalSettingsData {
48
+ if (cached) return cached;
49
+ const p = settingsPath();
50
+ if (!existsSync(p)) {
51
+ cached = {};
52
+ return cached;
53
+ }
54
+ try {
55
+ cached = JSON.parse(readFileSync(p, "utf-8")) as PalSettingsData;
56
+ return cached;
57
+ } catch {
58
+ cached = {};
59
+ return cached;
60
+ }
61
+ }
62
+
63
+ /** Force re-read from disk (useful after writes) */
64
+ export function reload(): PalSettingsData {
65
+ cached = null;
66
+ return load();
67
+ }
68
+
69
+ // ── Public API ──
70
+
71
+ /** Get the raw settings data */
72
+ export function raw(): PalSettingsData {
73
+ return load();
74
+ }
75
+
76
+ /** Get resolved identity with defaults */
77
+ export function identity(): Identity {
78
+ const data = load();
79
+ const ai = data.identity?.ai ?? {};
80
+ const principal = data.identity?.principal ?? {};
81
+ const name = ai.name || IDENTITY_DEFAULTS.ai.name;
82
+ const catchphrase = (ai.catchphrase || "").replace("{name}", name);
83
+
84
+ return {
85
+ ai: {
86
+ name,
87
+ fullName: ai.fullName || IDENTITY_DEFAULTS.ai.fullName,
88
+ displayName: ai.displayName || IDENTITY_DEFAULTS.ai.displayName,
89
+ catchphrase,
90
+ },
91
+ principal: {
92
+ name: principal.name || IDENTITY_DEFAULTS.principal.name,
93
+ timezone: principal.timezone || IDENTITY_DEFAULTS.principal.timezone,
94
+ },
95
+ };
96
+ }
97
+
98
+ /** Check if a dynamic context section is enabled (defaults to true) */
99
+ export function isEnabled(key: string): boolean {
100
+ return load().dynamicContext?.[key] !== false;
101
+ }
102
+
103
+ /** Get the loadAtStartup file list */
104
+ export function startupFiles(): string[] {
105
+ return load().loadAtStartup?.files ?? [];
106
+ }
107
+
108
+ /** Write settings back to disk and bust cache */
109
+ export function write(data: PalSettingsData): void {
110
+ writeFileSync(settingsPath(), `${JSON.stringify(data, null, 2)}\n`, "utf-8");
111
+ cached = null;
112
+ }
@@ -8,11 +8,13 @@ import { resolve } from "node:path";
8
8
  import { autoBackup } from "../handlers/backup";
9
9
  import { captureFailure } from "../handlers/failure";
10
10
  import { checkReflectTrigger } from "../handlers/reflect-trigger";
11
+ import { checkSelfModelTrigger } from "../handlers/self-model-trigger";
11
12
  import { captureSessionIntelligence } from "../handlers/session-intelligence";
12
13
  import { runSynthesis } from "../handlers/synthesis";
13
14
  import { resetTab } from "../handlers/tab";
14
15
  import { updateCounts } from "../handlers/update-counts";
15
16
  import { captureWorkSession } from "../handlers/work-session";
17
+ import { inference } from "./inference";
16
18
  import { logDebug, logError } from "./log";
17
19
  import { ensureDir, paths } from "./paths";
18
20
  import { extractContent, extractLastAssistant, parseMessages } from "./transcript";
@@ -44,6 +46,7 @@ export async function runStopHandlers(
44
46
  updateCounts(),
45
47
  autoBackup(),
46
48
  checkReflectTrigger(),
49
+ checkSelfModelTrigger(),
47
50
  runSynthesis(),
48
51
  ]);
49
52
 
@@ -137,15 +140,63 @@ async function checkPendingFailure(transcript: string): Promise<void> {
137
140
  rating: number;
138
141
  context: string;
139
142
  detailedContext?: string;
143
+ principle?: string;
140
144
  responsePreview?: string;
141
145
  userPreview?: string;
142
146
  };
143
147
  unlinkSync(pendingPath);
148
+
149
+ // Extract principle from full transcript if not already present
150
+ let { principle, detailedContext } = pending;
151
+ if (!principle) {
152
+ try {
153
+ const msgs = parseMessages(transcript);
154
+ const recent = msgs
155
+ .slice(-10)
156
+ .map((m) => `${m.role.toUpperCase()}: ${extractContent(m).slice(0, 300)}`)
157
+ .join("\n\n");
158
+
159
+ const result = await inference({
160
+ system: `Analyze this failed AI interaction. The user rated it ${pending.rating}/10.
161
+
162
+ Return JSON:
163
+ {
164
+ "principle": "<one actionable rule the AI should follow, 10-20 words. Start with a verb: 'Verify...', 'Always...', 'Never...', 'Ask before...'>",
165
+ "detailed_context": "<what went wrong and why, 50-150 words>"
166
+ }`,
167
+ user: `User feedback: ${pending.context}\n\nConversation:\n${recent}`,
168
+ maxTokens: 400,
169
+ timeout: 10000,
170
+ jsonSchema: {
171
+ type: "object" as const,
172
+ properties: {
173
+ principle: { type: "string" as const },
174
+ detailed_context: { type: "string" as const },
175
+ },
176
+ required: ["principle", "detailed_context"],
177
+ additionalProperties: false,
178
+ },
179
+ });
180
+
181
+ if (result.success && result.output) {
182
+ const parsed = JSON.parse(result.output) as {
183
+ principle?: string;
184
+ detailed_context?: string;
185
+ };
186
+ principle = parsed.principle || undefined;
187
+ if (!detailedContext) detailedContext = parsed.detailed_context || undefined;
188
+ }
189
+ } catch {
190
+ /* graceful fallback — capture without principle */
191
+ }
192
+ }
193
+
144
194
  await captureFailure(
145
195
  pending.rating,
146
196
  pending.context,
147
197
  transcript,
148
- pending.detailedContext
198
+ detailedContext,
199
+ principle
149
200
  );
150
201
  } catch {
151
202
  // Non-critical
@@ -660,21 +660,3 @@ export function countMd(dir: string): number {
660
660
  return 0;
661
661
  }
662
662
  }
663
-
664
- /** Read skill frontmatter field */
665
- export function readSkillField(skillPath: string, field: string): string {
666
- try {
667
- const content = readFileSync(skillPath, "utf-8");
668
- const match = content.match(new RegExp(`^${field}:\\s*(.+)$`, "m"));
669
- return match?.[1]?.trim() ?? "";
670
- } catch {
671
- return "";
672
- }
673
- }
674
-
675
- /** Strip frontmatter from a skill file (content after second ---) */
676
- export function skillBody(skillPath: string): string {
677
- const content = readFileSync(skillPath, "utf-8");
678
- const parts = content.split(/^---\s*$/m);
679
- return parts.length >= 3 ? parts.slice(2).join("---").trim() : content.trim();
680
- }