portable-agent-layer 0.1.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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -0
  3. package/assets/agents/claude-researcher.md +43 -0
  4. package/assets/agents/investigative-researcher.md +44 -0
  5. package/assets/agents/multi-perspective-researcher.md +43 -0
  6. package/assets/skills/analyze-pdf.md +40 -0
  7. package/assets/skills/analyze-youtube.md +35 -0
  8. package/assets/skills/council.md +43 -0
  9. package/assets/skills/create-skill.md +31 -0
  10. package/assets/skills/extract-entities.md +63 -0
  11. package/assets/skills/extract-wisdom.md +18 -0
  12. package/assets/skills/first-principles.md +17 -0
  13. package/assets/skills/fyzz-chat-api.md +43 -0
  14. package/assets/skills/reflect.md +87 -0
  15. package/assets/skills/research.md +68 -0
  16. package/assets/skills/review.md +19 -0
  17. package/assets/skills/summarize.md +15 -0
  18. package/assets/templates/AGENTS.md.template +45 -0
  19. package/assets/templates/telos/BELIEFS.md +4 -0
  20. package/assets/templates/telos/CHALLENGES.md +4 -0
  21. package/assets/templates/telos/GOALS.md +12 -0
  22. package/assets/templates/telos/IDEAS.md +4 -0
  23. package/assets/templates/telos/IDENTITY.md +4 -0
  24. package/assets/templates/telos/LEARNED.md +4 -0
  25. package/assets/templates/telos/MISSION.md +4 -0
  26. package/assets/templates/telos/MODELS.md +4 -0
  27. package/assets/templates/telos/NARRATIVES.md +4 -0
  28. package/assets/templates/telos/PROJECTS.md +7 -0
  29. package/assets/templates/telos/STRATEGIES.md +4 -0
  30. package/bin/pal +24 -0
  31. package/bin/pal.bat +8 -0
  32. package/bin/pal.ps1 +30 -0
  33. package/package.json +82 -0
  34. package/src/cli/index.ts +344 -0
  35. package/src/cli/install.ts +86 -0
  36. package/src/cli/uninstall.ts +45 -0
  37. package/src/hooks/LoadContext.ts +41 -0
  38. package/src/hooks/SecurityValidator.ts +52 -0
  39. package/src/hooks/SkillGuard.ts +41 -0
  40. package/src/hooks/StopOrchestrator.ts +35 -0
  41. package/src/hooks/UserPromptOrchestrator.ts +35 -0
  42. package/src/hooks/handlers/backup.ts +41 -0
  43. package/src/hooks/handlers/failure.ts +136 -0
  44. package/src/hooks/handlers/rating.ts +409 -0
  45. package/src/hooks/handlers/relationship.ts +113 -0
  46. package/src/hooks/handlers/session-name.ts +121 -0
  47. package/src/hooks/handlers/synthesis.ts +109 -0
  48. package/src/hooks/handlers/tab.ts +8 -0
  49. package/src/hooks/handlers/update-counts.ts +151 -0
  50. package/src/hooks/handlers/work-learning.ts +183 -0
  51. package/src/hooks/handlers/work-session.ts +58 -0
  52. package/src/hooks/lib/claude-md.ts +121 -0
  53. package/src/hooks/lib/context.ts +433 -0
  54. package/src/hooks/lib/entities.ts +304 -0
  55. package/src/hooks/lib/export.ts +76 -0
  56. package/src/hooks/lib/inference.ts +91 -0
  57. package/src/hooks/lib/learning-category.ts +14 -0
  58. package/src/hooks/lib/log.ts +53 -0
  59. package/src/hooks/lib/models.ts +16 -0
  60. package/src/hooks/lib/paths.ts +80 -0
  61. package/src/hooks/lib/relationship.ts +135 -0
  62. package/src/hooks/lib/security.ts +122 -0
  63. package/src/hooks/lib/session-names.ts +247 -0
  64. package/src/hooks/lib/setup.ts +189 -0
  65. package/src/hooks/lib/signal-trends.ts +117 -0
  66. package/src/hooks/lib/signals.ts +37 -0
  67. package/src/hooks/lib/stdin.ts +18 -0
  68. package/src/hooks/lib/stop.ts +155 -0
  69. package/src/hooks/lib/time.ts +19 -0
  70. package/src/hooks/lib/token-usage.ts +42 -0
  71. package/src/hooks/lib/transcript.ts +76 -0
  72. package/src/hooks/lib/wisdom.ts +48 -0
  73. package/src/hooks/lib/work-tracking.ts +193 -0
  74. package/src/hooks/setup-check.ts +42 -0
  75. package/src/targets/claude/install.ts +145 -0
  76. package/src/targets/claude/uninstall.ts +101 -0
  77. package/src/targets/lib.ts +337 -0
  78. package/src/targets/opencode/install.ts +59 -0
  79. package/src/targets/opencode/plugin.ts +328 -0
  80. package/src/targets/opencode/uninstall.ts +57 -0
  81. package/src/tools/entity-save.ts +110 -0
  82. package/src/tools/export.ts +34 -0
  83. package/src/tools/fyzz-api.ts +104 -0
  84. package/src/tools/import.ts +123 -0
  85. package/src/tools/pattern-synthesis.ts +435 -0
  86. package/src/tools/pdf-download.ts +102 -0
  87. package/src/tools/relationship-reflect.ts +362 -0
  88. package/src/tools/session-summary.ts +206 -0
  89. package/src/tools/token-cost.ts +301 -0
  90. package/src/tools/youtube-analyze.ts +105 -0
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Dynamic AGENTS.md generation.
3
+ *
4
+ * AGENTS.md is regenerated when setup.json or any telos file is newer than
5
+ * the existing AGENTS.md. The template lives at AGENTS.md.template.
6
+ * CLAUDE.md is kept as a symlink pointing to AGENTS.md.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ lstatSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ statSync,
15
+ symlinkSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { dirname, relative, resolve } from "node:path";
20
+ import { loadTelos } from "./context";
21
+ import { assets, palHome, paths, platform } from "./paths";
22
+ import { buildSetupPrompt, readSetupState } from "./setup";
23
+
24
+ const TEMPLATE_PATH = assets.agentsMdTemplate();
25
+
26
+ function getOutputPaths() {
27
+ const opencodeDir = platform.opencodeDir();
28
+ const claudeDir = platform.claudeDir();
29
+ if (!opencodeDir || !claudeDir) {
30
+ throw new Error("PAL_OPENCODE_DIR or PAL_CLAUDE_DIR not set");
31
+ }
32
+ return {
33
+ outputPath: resolve(opencodeDir, "AGENTS.md"),
34
+ symlinkPath: resolve(claudeDir, "CLAUDE.md"),
35
+ };
36
+ }
37
+
38
+ function latestMtime(...filePaths: string[]): number {
39
+ let latest = 0;
40
+ for (const p of filePaths) {
41
+ if (!existsSync(p)) continue;
42
+ try {
43
+ const mt = statSync(p).mtimeMs;
44
+ if (mt > latest) latest = mt;
45
+ } catch {
46
+ /* skip */
47
+ }
48
+ }
49
+ return latest;
50
+ }
51
+
52
+ /** Ensure CLAUDE.md is a symlink pointing to AGENTS.md */
53
+ function ensureSymlink(): void {
54
+ const { outputPath, symlinkPath } = getOutputPaths();
55
+ try {
56
+ const stat = lstatSync(symlinkPath);
57
+ // If it exists but isn't a symlink (e.g. old generated file), remove it
58
+ if (!stat.isSymbolicLink()) unlinkSync(symlinkPath);
59
+ else return; // already a symlink, leave it
60
+ } catch {
61
+ // doesn't exist — create it
62
+ }
63
+ const relTarget = relative(dirname(symlinkPath), outputPath).replaceAll("\\", "/");
64
+ symlinkSync(relTarget, symlinkPath);
65
+ }
66
+
67
+ /** Returns true if AGENTS.md needs to be regenerated */
68
+ export function needsRebuild(): boolean {
69
+ const { outputPath } = getOutputPaths();
70
+ if (!existsSync(outputPath)) return true;
71
+
72
+ const outputMtime = statSync(outputPath).mtimeMs;
73
+
74
+ // Collect source files: template + setup.json + all telos/*.md
75
+ const sources: string[] = [TEMPLATE_PATH, resolve(paths.state(), "setup.json")];
76
+
77
+ const telosDir = paths.telos();
78
+ if (existsSync(telosDir)) {
79
+ for (const f of readdirSync(telosDir).filter((f) => f.endsWith(".md"))) {
80
+ sources.push(resolve(telosDir, f));
81
+ }
82
+ }
83
+
84
+ return latestMtime(...sources) > outputMtime;
85
+ }
86
+
87
+ function memoryPaths(): string {
88
+ const mem = resolve(palHome(), "memory");
89
+ return [
90
+ `- **Wisdom frames**: \`${resolve(mem, "wisdom", "frames")}/\` — crystallized principles per domain (loaded every session)`,
91
+ `- **Relationship notes**: \`${resolve(mem, "relationship")}/YYYY-MM/YYYY-MM-DD.md\` — daily interaction observations (loaded every session)`,
92
+ `- **Session learnings**: \`${resolve(mem, "learning", "session")}/YYYY-MM/*.md\` — reusable insights from sessions (loaded every session)`,
93
+ `- **Failure captures**: \`${resolve(mem, "learning", "failures")}/YYYY-MM/{timestamp}_{slug}/capture.md\` — what went wrong and why`,
94
+ `- **Signals**: \`${resolve(mem, "signals")}/ratings.jsonl\` — append-only rating signal log (do not edit directly)`,
95
+ ].join("\n");
96
+ }
97
+
98
+ /** Render AGENTS.md from the template using current state */
99
+ export function buildClaudeMd(): string {
100
+ const template = existsSync(TEMPLATE_PATH)
101
+ ? readFileSync(TEMPLATE_PATH, "utf-8")
102
+ : "# PAL Context\n\n{{SETUP_PROMPT}}\n{{TELOS}}\n## Memory\n\n{{MEMORY_PATHS}}\n";
103
+
104
+ const state = readSetupState();
105
+ const setupPrompt = state ? buildSetupPrompt(state) : null;
106
+ const telos = loadTelos();
107
+
108
+ return template
109
+ .replace("{{SETUP_PROMPT}}", setupPrompt ? `${setupPrompt}\n` : "")
110
+ .replace("{{TELOS}}", telos ? `${telos}\n` : "")
111
+ .replace("{{MEMORY_PATHS}}", memoryPaths());
112
+ }
113
+
114
+ /** Regenerate AGENTS.md if any source file is newer, and ensure CLAUDE.md symlink exists. Returns true if rebuilt. */
115
+ export function regenerateIfNeeded(): boolean {
116
+ const { outputPath } = getOutputPaths();
117
+ ensureSymlink();
118
+ if (!needsRebuild()) return false;
119
+ writeFileSync(outputPath, buildClaudeMd(), "utf-8");
120
+ return true;
121
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Shared context builders for session startup.
3
+ * Used by LoadContext.ts (Claude Code) and the opencode plugin.
4
+ */
5
+
6
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { paths } from "./paths";
9
+ import { loadRecentNotes } from "./relationship";
10
+ import { readSessionNames } from "./session-names";
11
+ import { buildSetupPrompt, readSetupState, remainingSteps, STEP_ORDER } from "./setup";
12
+ import { computeSignalTrends, formatTrends } from "./signal-trends";
13
+ import { readFramePrinciples } from "./wisdom";
14
+ import {
15
+ activeProjects,
16
+ readSessions,
17
+ recentSessions,
18
+ staleProjects,
19
+ } from "./work-tracking";
20
+
21
+ /** Load all populated TELOS files as a single markdown string */
22
+ export function loadTelos(): string {
23
+ const telosDir = paths.telos();
24
+ if (!existsSync(telosDir)) return "";
25
+
26
+ const files = readdirSync(telosDir)
27
+ .filter((f) => f.endsWith(".md"))
28
+ .sort();
29
+
30
+ const sections: string[] = [];
31
+
32
+ for (const file of files) {
33
+ const content = readFileSync(resolve(telosDir, file), "utf-8").trim();
34
+ // Skip empty templates (only have a heading and comment)
35
+ const realLines = content
36
+ .split("\n")
37
+ .filter(
38
+ (l) =>
39
+ !l.startsWith("#") && !l.startsWith("<!--") && !l.startsWith("-->") && l.trim()
40
+ );
41
+ if (realLines.length === 0) continue;
42
+ sections.push(content);
43
+ }
44
+
45
+ return sections.join("\n\n---\n\n");
46
+ }
47
+
48
+ /** Count lines in a signals JSONL file */
49
+ export function countSignals(filename: string): number {
50
+ const filepath = resolve(paths.signals(), filename);
51
+ if (!existsSync(filepath)) return 0;
52
+ try {
53
+ const content = readFileSync(filepath, "utf-8").trim();
54
+ return content ? content.split("\n").length : 0;
55
+ } catch {
56
+ return 0;
57
+ }
58
+ }
59
+
60
+ /** Load structured session history + project dashboard */
61
+ export function loadActiveWork(): { text: string; summary: string | null } | null {
62
+ try {
63
+ const recent = recentSessions(48);
64
+ const projects = activeProjects();
65
+ const stale = staleProjects(7);
66
+
67
+ if (recent.length === 0 && projects.length === 0) return null;
68
+
69
+ const lines: string[] = [];
70
+
71
+ if (recent.length > 0) {
72
+ lines.push("## Recent Work (last 48h)");
73
+ for (const s of recent.slice(-10).reverse()) {
74
+ const ago = formatAgo(s.ts);
75
+ lines.push(`- [${s.status}] ${s.name} — ${ago}`);
76
+ if (s.handoff) {
77
+ lines.push(` Handoff: ${s.handoff.split("\n")[0].slice(0, 120)}`);
78
+ }
79
+ }
80
+ }
81
+
82
+ if (projects.length > 0) {
83
+ lines.push("", "### Active Projects");
84
+ for (const p of projects) {
85
+ const sessionCount = p.sessions.length;
86
+ const ago = formatAgo(p.updated);
87
+ lines.push(`- **${p.name}** (${sessionCount} sessions, last: ${ago})`);
88
+ if (p.nextSteps.length > 0) {
89
+ lines.push(` Next: ${p.nextSteps[0]}`);
90
+ }
91
+ if (p.blockers.length > 0) {
92
+ lines.push(` Blockers: ${p.blockers.join(", ")}`);
93
+ } else {
94
+ lines.push(" Blockers: None");
95
+ }
96
+ }
97
+ }
98
+
99
+ if (stale.length > 0) {
100
+ lines.push("", "### Stale Projects (>7d inactive)");
101
+ for (const p of stale) {
102
+ lines.push(`- **${p.name}** — last active ${formatAgo(p.updated)}`);
103
+ }
104
+ }
105
+
106
+ // Summary from most recent session
107
+ const last = recent.length > 0 ? recent[recent.length - 1] : null;
108
+ const summary = last?.summary?.slice(0, 60) || null;
109
+
110
+ return {
111
+ text: lines.join("\n"),
112
+ summary: summary ? `"${summary}"` : null,
113
+ };
114
+ } catch {
115
+ return null;
116
+ }
117
+ }
118
+
119
+ /** Format a timestamp as a human-readable "X ago" string */
120
+ function formatAgo(ts: string): string {
121
+ const diff = Date.now() - new Date(ts).getTime();
122
+ const hours = Math.floor(diff / (1000 * 60 * 60));
123
+ if (hours < 1) return "just now";
124
+ if (hours < 24) return `${hours}h ago`;
125
+ const days = Math.floor(hours / 24);
126
+ return `${days}d ago`;
127
+ }
128
+
129
+ /** Load the N most recent session names (fallback for greeting) */
130
+ export function loadRecentSessions(count: number): string[] {
131
+ try {
132
+ const sessions = readSessions();
133
+ if (sessions.length > 0) {
134
+ return sessions
135
+ .slice(-count)
136
+ .reverse()
137
+ .map((s) => s.name);
138
+ }
139
+ // Fallback to session-names.json for backwards compat
140
+ const names = readSessionNames();
141
+ const entries = Object.values(names);
142
+ return entries.slice(-count).reverse();
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ /** Read cached counts from counts.json, falling back to live counting */
149
+ function loadCachedCounts(): {
150
+ signals: number;
151
+ telos: number;
152
+ skills: number;
153
+ sessions: number;
154
+ } {
155
+ try {
156
+ const countsPath = resolve(paths.state(), "counts.json");
157
+ if (existsSync(countsPath)) {
158
+ return JSON.parse(readFileSync(countsPath, "utf-8"));
159
+ }
160
+ } catch {
161
+ /* fall through */
162
+ }
163
+ // Fallback: count live (first session before any stop has run)
164
+ return {
165
+ signals: countSignals("ratings.jsonl"),
166
+ telos: 0,
167
+ skills: 0,
168
+ sessions: 0,
169
+ };
170
+ }
171
+
172
+ /** Build the visible greeting lines for stderr */
173
+ export function buildGreeting(): string[] {
174
+ const counts = loadCachedCounts();
175
+ const work = loadActiveWork();
176
+ const setupState = readSetupState();
177
+ const setupPrompt = setupState ? buildSetupPrompt(setupState) : null;
178
+
179
+ const greeting: string[] = [];
180
+
181
+ if (setupPrompt) {
182
+ const done = STEP_ORDER.length - (setupState ? remainingSteps(setupState).length : 0);
183
+ greeting.push(
184
+ `🔧 PAL setup ${done}/${STEP_ORDER.length} | ${counts.signals} signals`
185
+ );
186
+ } else {
187
+ greeting.push(
188
+ `✅ PAL ready | ${counts.telos} TELOS | ${counts.skills} skills | ${counts.signals} signals | ${counts.sessions} sessions`
189
+ );
190
+ }
191
+
192
+ if (work?.summary) {
193
+ greeting.push(`📋 Previous: ${work.summary}`);
194
+ }
195
+
196
+ // Show recent session names for quick context
197
+ const recent = loadRecentSessions(3);
198
+ if (recent.length > 0) {
199
+ greeting.push(`📂 Recent: ${recent.join(" | ")}`);
200
+ }
201
+
202
+ return greeting;
203
+ }
204
+
205
+ /** Load high-confidence wisdom principles for injection into system-reminder */
206
+ export function loadWisdomContext(): string {
207
+ try {
208
+ const principles = readFramePrinciples();
209
+ if (principles.length === 0) return "";
210
+ return ["## Crystallized Principles", ...principles.map((p) => `- ${p}`)].join("\n");
211
+ } catch {
212
+ return "";
213
+ }
214
+ }
215
+
216
+ /** Load recent session learning files as digest, split by category */
217
+ export function loadLearningDigest(): string {
218
+ try {
219
+ const sessionDir = paths.sessionLearning();
220
+ if (!existsSync(sessionDir)) return "";
221
+
222
+ const files: { path: string; category: string }[] = [];
223
+ // Structure: session/{year}/{month}/*.md
224
+ for (const year of readdirSync(sessionDir).sort().reverse()) {
225
+ const yearDir = resolve(sessionDir, year);
226
+ try {
227
+ for (const month of readdirSync(yearDir).sort().reverse()) {
228
+ const monthDir = resolve(yearDir, month);
229
+ try {
230
+ const monthFiles = readdirSync(monthDir)
231
+ .filter((f) => f.endsWith(".md"))
232
+ .sort()
233
+ .reverse()
234
+ .map((f) => {
235
+ const category = f.includes("_system") ? "system" : "algorithm";
236
+ return { path: resolve(monthDir, f), category };
237
+ });
238
+ files.push(...monthFiles);
239
+ } catch {
240
+ /* skip */
241
+ }
242
+ if (files.length >= 6) break;
243
+ }
244
+ } catch {
245
+ /* skip */
246
+ }
247
+ if (files.length >= 6) break;
248
+ }
249
+
250
+ function extractTitle(filePath: string): string {
251
+ const content = readFileSync(filePath, "utf-8").trim();
252
+ const titleLine = content.split("\n").find((l) => l.startsWith("**Title:**"));
253
+ if (titleLine) return titleLine;
254
+ // Fallback: first non-heading, non-empty line
255
+ const fallback = content.split("\n").find((l) => l.trim() && !l.startsWith("#"));
256
+ return fallback?.slice(0, 100) ?? content.slice(0, 80);
257
+ }
258
+
259
+ const algorithm = files.filter((f) => f.category === "algorithm").slice(0, 2);
260
+ const system = files.filter((f) => f.category === "system").slice(0, 2);
261
+
262
+ if (algorithm.length === 0 && system.length === 0) return "";
263
+
264
+ const lines: string[] = ["## Recent Session Learnings"];
265
+ if (algorithm.length > 0) {
266
+ lines.push("### Approach");
267
+ for (const f of algorithm) lines.push(`- ${extractTitle(f.path)}`);
268
+ }
269
+ if (system.length > 0) {
270
+ lines.push("### System");
271
+ for (const f of system) lines.push(`- ${extractTitle(f.path)}`);
272
+ }
273
+ return lines.join("\n");
274
+ } catch {
275
+ return "";
276
+ }
277
+ }
278
+
279
+ /** Load 5 most recent failure contexts as an "avoid" list */
280
+ export function loadFailurePatterns(): string {
281
+ try {
282
+ const failuresDir = paths.failures();
283
+ if (!existsSync(failuresDir)) return "";
284
+
285
+ // Structure: failures/{year}/{month}/{timestamp}_{slug}/
286
+ const failures: string[] = [];
287
+ for (const year of readdirSync(failuresDir).sort().reverse()) {
288
+ const yearPath = resolve(failuresDir, year);
289
+ for (const month of readdirSync(yearPath).sort().reverse()) {
290
+ const monthPath = resolve(yearPath, month);
291
+ try {
292
+ const dirs = readdirSync(monthPath).sort().reverse();
293
+ for (const dir of dirs) {
294
+ if (!/^\d{8}-\d{6}_/.test(dir)) continue;
295
+ // Read context from sentiment.json for a meaningful description
296
+ const sentimentPath = resolve(monthPath, dir, "sentiment.json");
297
+ if (existsSync(sentimentPath)) {
298
+ try {
299
+ const data = JSON.parse(readFileSync(sentimentPath, "utf-8")) as {
300
+ rating?: number;
301
+ context?: string;
302
+ };
303
+ if (data.context) {
304
+ const label = data.rating ? `[${data.rating}/10]` : "";
305
+ failures.push(`${label} ${data.context}`.trim());
306
+ }
307
+ } catch {
308
+ // Fall back to slug from directory name
309
+ failures.push(dir.replace(/^\d{8}-\d{6}_/, ""));
310
+ }
311
+ } else {
312
+ failures.push(dir.replace(/^\d{8}-\d{6}_/, ""));
313
+ }
314
+ if (failures.length >= 5) break;
315
+ }
316
+ } catch {
317
+ /* skip */
318
+ }
319
+ if (failures.length >= 5) break;
320
+ }
321
+ if (failures.length >= 5) break;
322
+ }
323
+
324
+ if (failures.length === 0) return "";
325
+ return ["## Recent Failure Patterns (Avoid)", ...failures.map((f) => `- ${f}`)].join(
326
+ "\n"
327
+ );
328
+ } catch {
329
+ return "";
330
+ }
331
+ }
332
+
333
+ /** Load recommendations from the most recent synthesis report */
334
+ export function loadSynthesisRecommendations(): string {
335
+ try {
336
+ const synthDir = paths.synthesis();
337
+ if (!existsSync(synthDir)) return "";
338
+
339
+ // Find most recent month directory
340
+ const months = readdirSync(synthDir).sort().reverse();
341
+ for (const month of months) {
342
+ const monthDir = resolve(synthDir, month);
343
+ try {
344
+ const files = readdirSync(monthDir)
345
+ .filter((f) => f.endsWith(".md"))
346
+ .sort()
347
+ .reverse();
348
+ if (files.length === 0) continue;
349
+
350
+ const content = readFileSync(resolve(monthDir, files[0]), "utf-8");
351
+
352
+ // Extract recommendations section
353
+ const recMatch = content.match(/## Recommendations\n\n([\s\S]*?)(?:\n##|\n$|$)/);
354
+ if (!recMatch?.[1]?.trim()) continue;
355
+
356
+ const recs = recMatch[1]
357
+ .trim()
358
+ .split("\n")
359
+ .filter((l) => l.trim())
360
+ .slice(0, 4);
361
+
362
+ if (recs.length === 0) continue;
363
+
364
+ // Extract metadata
365
+ const periodMatch = content.match(/\*\*Period:\*\* (.+)/);
366
+ const avgMatch = content.match(/\*\*Average Rating:\*\* (.+)/);
367
+ const header = [
368
+ "## Pattern Synthesis",
369
+ periodMatch ? `*${periodMatch[1]} — ${avgMatch?.[1] ?? ""}*` : "",
370
+ ]
371
+ .filter(Boolean)
372
+ .join("\n");
373
+
374
+ return [header, ...recs].join("\n");
375
+ } catch {}
376
+ }
377
+ return "";
378
+ } catch {
379
+ return "";
380
+ }
381
+ }
382
+
383
+ /** Load signal trends as a formatted string */
384
+ export function loadSignalTrends(): string {
385
+ try {
386
+ return formatTrends(computeSignalTrends());
387
+ } catch {
388
+ return "";
389
+ }
390
+ }
391
+
392
+ /** Load recent relationship notes (today + yesterday) */
393
+ export function loadRelationshipContext(): string {
394
+ try {
395
+ const notes = loadRecentNotes(2);
396
+ if (!notes) return "";
397
+ return `## Recent Interaction Notes\n${notes}`;
398
+ } catch {
399
+ return "";
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Build the <system-reminder> content for the AI.
405
+ *
406
+ * Static context (TELOS, setup prompt) lives in AGENTS.md / CLAUDE.md and is
407
+ * loaded natively by Claude Code / opencode. This injects dynamic context only —
408
+ * things that change per-session and can't live in a static file.
409
+ */
410
+ export function buildSystemReminder(): string {
411
+ const work = loadActiveWork();
412
+ const wisdom = loadWisdomContext();
413
+ const relationship = loadRelationshipContext();
414
+ const digest = loadLearningDigest();
415
+ const trends = loadSignalTrends();
416
+ const failures = loadFailurePatterns();
417
+ const synthesis = loadSynthesisRecommendations();
418
+ const parts: string[] = [];
419
+ if (wisdom) parts.push(wisdom);
420
+ if (relationship) parts.push(relationship);
421
+ if (digest) parts.push(digest);
422
+ if (synthesis) parts.push(synthesis);
423
+ if (trends) parts.push(trends);
424
+ if (failures) parts.push(failures);
425
+ if (work) parts.push(work.text);
426
+
427
+ if (parts.length === 0) return "";
428
+
429
+ const now = new Date();
430
+ const time = `**Current time:** ${now.toISOString().slice(0, 19).replace("T", " ")} UTC`;
431
+
432
+ return ["<system-reminder>", time, ...parts, "</system-reminder>"].join("\n");
433
+ }