portable-agent-layer 0.71.0 → 0.72.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/cli/migrate.ts +1 -1
  3. package/src/cli/skill.ts +1 -1
  4. package/src/hooks/CompactRecover.ts +28 -86
  5. package/src/hooks/LedgerUnapplied.ts +3 -28
  6. package/src/hooks/LoadContext.ts +33 -60
  7. package/src/hooks/SecurityValidator.ts +16 -109
  8. package/src/hooks/handlers/failure-principle.ts +19 -44
  9. package/src/hooks/handlers/session-intelligence.ts +13 -70
  10. package/src/hooks/lib/capture-store.ts +103 -0
  11. package/src/hooks/lib/compact-recall.ts +89 -0
  12. package/src/hooks/lib/failure-principle.ts +98 -0
  13. package/src/hooks/lib/ledger-hook.ts +35 -0
  14. package/src/hooks/lib/ledger.ts +48 -1
  15. package/src/hooks/lib/security-gate.ts +159 -0
  16. package/src/hooks/lib/session-context.ts +74 -0
  17. package/src/tools/agent/algorithm-reflect.ts +28 -97
  18. package/src/tools/agent/analyze.ts +19 -120
  19. package/src/tools/agent/handoff-note.ts +29 -77
  20. package/src/tools/agent/project.ts +13 -134
  21. package/src/tools/agent/relationship-note.ts +27 -46
  22. package/src/tools/agent/synthesize.ts +1 -1
  23. package/src/tools/agent/thread.ts +43 -123
  24. package/src/tools/control-room/data.ts +2 -2
  25. package/src/tools/control-room/matrix.ts +1 -1
  26. package/src/tools/control-room/ui/ledger.tsx +2 -1
  27. package/src/tools/ledger/view.ts +3 -0
  28. package/src/tools/lib/algorithm-reflect.ts +84 -0
  29. package/src/tools/lib/analyze-report.ts +120 -0
  30. package/src/tools/lib/handoff-note.ts +88 -0
  31. package/src/tools/lib/note-flags.ts +59 -0
  32. package/src/tools/lib/project-isc.ts +151 -0
  33. package/src/tools/lib/relationship-reflect.ts +402 -0
  34. package/src/tools/lib/self-model.ts +499 -0
  35. package/src/tools/lib/session-usage.ts +216 -0
  36. package/src/tools/lib/skill-doctor.ts +457 -0
  37. package/src/tools/lib/thread.ts +119 -0
  38. package/src/tools/lib/token-report.ts +173 -0
  39. package/src/tools/lib/transcript-usage.ts +42 -0
  40. package/src/tools/lib/usage-buckets.ts +329 -0
  41. package/src/tools/relationship-reflect.ts +48 -412
  42. package/src/tools/self-model.ts +76 -558
  43. package/src/tools/session-summary.ts +8 -215
  44. package/src/tools/skill-doctor.ts +9 -444
  45. package/src/tools/token-cost.ts +18 -428
@@ -6,16 +6,16 @@
6
6
  * first-person reflection. Reads opinions, ratings, wisdom frames,
7
7
  * graduated failure patterns, algorithm reflections, relationship notes,
8
8
  * and session history. Produces a self-aware narrative at
9
- * ~/.pal/memory/self-model.md that is injected at session start.
9
+ * ~/.pal/memory/self-model/current.md that is injected at session start.
10
10
  *
11
11
  * Usage:
12
- * bun ~/.pal/tools/self-model.ts [--days 30] [--force]
12
+ * bun ~/.pal/tools/self-model.ts [--days 30] [--force] [--dry-run]
13
13
  *
14
- * Guards: skips if last synthesis was < 24h ago (unless --force).
15
- * Output: ~/.pal/memory/self-model.md
14
+ * Every decision this makes lives in ./lib/self-model.ts, where the suite
15
+ * reaches it directly; what stays here is paths, inference and the writes.
16
16
  */
17
17
 
18
- import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
19
19
  import { resolve } from "node:path";
20
20
  import { parseArgs } from "node:util";
21
21
  import { inference } from "../hooks/lib/inference";
@@ -23,512 +23,67 @@ import { SONNET_MODEL } from "../hooks/lib/models";
23
23
  import { ensureDir, paths } from "../hooks/lib/paths";
24
24
  import { identity as loadSettingsIdentity } from "../hooks/lib/settings";
25
25
  import { logTokenUsage } from "../hooks/lib/token-usage";
26
+ import {
27
+ archiveDateOf,
28
+ buildPrompt,
29
+ failedSynthesisModel,
30
+ formatDataForInference,
31
+ gatherData,
32
+ inferenceUserContent,
33
+ metaFooter,
34
+ type SelfModelSources,
35
+ synthesisIsDue,
36
+ } from "./lib/self-model";
37
+
38
+ const HELP = `
39
+ SelfModel — Synthesize a first-person self-model from accumulated data
26
40
 
27
- // ── Config ──
28
-
29
- const SELF_MODEL_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
30
-
31
- // ── Types ──
32
-
33
- interface Opinion {
34
- id: string;
35
- statement: string;
36
- confidence: number;
37
- category: string;
38
- evidence: { date: string; type: string; source: string }[];
39
- created: string;
40
- updated: string;
41
- }
42
-
43
- interface Rating {
44
- ts: string;
45
- type: string;
46
- rating: number;
47
- context: string;
48
- source: string;
49
- }
50
-
51
- interface GraduatedPattern {
52
- pattern: string;
53
- domain: string;
54
- confidence: number;
55
- occurrences: number;
56
- sources: string[];
57
- graduatedAt: string;
58
- }
59
-
60
- interface AlgorithmReflection {
61
- timestamp: string;
62
- cwd?: string;
63
- task: string;
64
- criteria_count: number;
65
- criteria_passed: number;
66
- criteria_failed: number;
67
- sentiment: number;
68
- q1: string;
69
- q2: string;
70
- q3: string;
71
- }
41
+ Usage:
42
+ bun self-model.ts [--days 30] [--force] [--dry-run]
72
43
 
73
- interface RelationshipNote {
74
- type: "O" | "W" | "Session";
75
- content: string;
76
- confidence?: number;
77
- date: string;
78
- }
44
+ Options:
45
+ --days Lookback window (default: 30)
46
+ --force Skip 24h guard
47
+ --dry-run Print to stdout without writing
79
48
 
80
- // ── Helpers ──
49
+ Output: ~/.pal/memory/self-model/current.md (synthesized by Sonnet)
50
+ `;
81
51
 
82
52
  function selfModelDir(): string {
83
53
  return ensureDir(resolve(paths.memory(), "self-model"));
84
54
  }
85
55
 
86
- function selfModelPath(): string {
87
- return resolve(selfModelDir(), "current.md");
88
- }
89
-
90
- function selfModelMetaPath(): string {
91
- return resolve(selfModelDir(), "meta.json");
92
- }
93
-
94
- function archiveDir(): string {
95
- return ensureDir(resolve(selfModelDir(), "archive"));
96
- }
97
-
98
- function shouldRun(force: boolean): boolean {
99
- if (force) return true;
100
- const p = selfModelMetaPath();
101
- if (!existsSync(p)) return true;
102
- try {
103
- const meta = JSON.parse(readFileSync(p, "utf-8")) as { timestamp: string };
104
- return Date.now() - new Date(meta.timestamp).getTime() > SELF_MODEL_TTL_MS;
105
- } catch {
106
- return true;
107
- }
108
- }
109
-
110
- export function readJsonl<T>(path: string): T[] {
111
- if (!existsSync(path)) return [];
112
- try {
113
- return readFileSync(path, "utf-8")
114
- .split("\n")
115
- .filter((l) => l.trim())
116
- .map((l) => JSON.parse(l) as T);
117
- } catch {
118
- return [];
119
- }
120
- }
121
-
122
- function safeReadJson<T>(path: string, fallback: T): T {
123
- if (!existsSync(path)) return fallback;
124
- try {
125
- return JSON.parse(readFileSync(path, "utf-8")) as T;
126
- } catch {
127
- return fallback;
128
- }
129
- }
130
-
131
- function safeReaddir(dir: string): string[] {
132
- try {
133
- return readdirSync(dir);
134
- } catch {
135
- return [];
136
- }
137
- }
138
-
139
- function daysAgo(days: number): Date {
140
- return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
141
- }
142
-
143
- function formatDate(iso: string): string {
144
- return iso.slice(0, 10);
145
- }
146
-
147
- function round1(n: number): number {
148
- return Math.round(n * 10) / 10;
149
- }
150
-
151
- // ── Data Readers ──
152
-
153
- function readOpinions(): Opinion[] {
154
- const data = safeReadJson<{ opinions?: Opinion[] }>(
155
- resolve(paths.relationship(), "opinions.json"),
156
- { opinions: [] }
157
- );
158
- return (data.opinions ?? []).sort((a, b) => b.confidence - a.confidence);
159
- }
160
-
161
- function readRatings(since: Date): {
162
- count: number;
163
- avg: number;
164
- recentAvg: number;
165
- lowCount: number;
166
- highCount: number;
167
- trend: "improving" | "declining" | "stable";
168
- recentContexts: string[];
169
- } {
170
- const all = readJsonl<Rating>(resolve(paths.signals(), "ratings.jsonl"));
171
- const ratings = all.filter((r) => new Date(r.ts) >= since);
172
-
173
- if (ratings.length === 0) {
174
- return {
175
- count: 0,
176
- avg: 0,
177
- recentAvg: 0,
178
- lowCount: 0,
179
- highCount: 0,
180
- trend: "stable",
181
- recentContexts: [],
182
- };
183
- }
184
-
185
- const avg = ratings.reduce((s, r) => s + r.rating, 0) / ratings.length;
186
- const recent = ratings.slice(-10);
187
- const recentAvg = recent.reduce((s, r) => s + r.rating, 0) / recent.length;
188
- const lowCount = ratings.filter((r) => r.rating <= 3).length;
189
- const highCount = ratings.filter((r) => r.rating >= 8).length;
190
-
191
- // Trend
192
- const mid = Math.floor(ratings.length / 2);
193
- let trend: "improving" | "declining" | "stable" = "stable";
194
- if (mid >= 3) {
195
- const firstAvg = ratings.slice(0, mid).reduce((s, r) => s + r.rating, 0) / mid;
196
- const secondAvg =
197
- ratings.slice(mid).reduce((s, r) => s + r.rating, 0) / (ratings.length - mid);
198
- if (secondAvg - firstAvg > 0.5) trend = "improving";
199
- else if (secondAvg - firstAvg < -0.5) trend = "declining";
200
- }
201
-
202
- // Recent low-rating contexts for weakness detection
203
- const recentContexts = ratings
204
- .filter((r) => r.rating <= 3 && r.context)
205
- .slice(-5)
206
- .map((r) => r.context);
56
+ const selfModelPath = () => resolve(selfModelDir(), "current.md");
57
+ const selfModelMetaPath = () => resolve(selfModelDir(), "meta.json");
207
58
 
59
+ function sources(): SelfModelSources {
208
60
  return {
209
- count: ratings.length,
210
- avg: round1(avg),
211
- recentAvg: round1(recentAvg),
212
- lowCount,
213
- highCount,
214
- trend,
215
- recentContexts,
61
+ opinionsFile: resolve(paths.relationship(), "opinions.json"),
62
+ ratingsFile: resolve(paths.signals(), "ratings.jsonl"),
63
+ wisdomDir: paths.wisdom(),
64
+ graduatedFile: resolve(paths.wisdomState(), "graduated.json"),
65
+ reflectionsFile: resolve(paths.reflections(), "algorithm-reflections.jsonl"),
66
+ relationshipDir: paths.relationship(),
67
+ sessionDir: resolve(paths.learning(), "session"),
216
68
  };
217
69
  }
218
70
 
219
- function readWisdomFrames(): { domain: string; principles: string[] }[] {
220
- const framesDir = paths.wisdom();
221
- const frames: { domain: string; principles: string[] }[] = [];
222
-
223
- for (const file of safeReaddir(framesDir).filter((f) => f.endsWith(".md"))) {
224
- const domain = file.replace(/\.md$/, "");
225
- const content = readFileSync(resolve(framesDir, file), "utf-8");
226
-
227
- // Extract CRYSTAL principles
228
- const principles = content
229
- .split("\n")
230
- .filter((line) => line.includes("[CRYSTAL:"))
231
- .map((line) =>
232
- line
233
- .replace(/^-\s*/, "")
234
- .replace(/\s*\[CRYSTAL:.*$/, "")
235
- .trim()
236
- );
237
-
238
- if (principles.length > 0) {
239
- frames.push({ domain, principles });
240
- }
241
- }
242
-
243
- return frames;
244
- }
245
-
246
- function readGraduatedPatterns(): GraduatedPattern[] {
247
- return (
248
- safeReadJson<{ graduated?: GraduatedPattern[] }>(
249
- resolve(paths.wisdomState(), "graduated.json"),
250
- { graduated: [] }
251
- ).graduated ?? []
252
- );
253
- }
254
-
255
- function readAlgorithmReflections(since: Date): AlgorithmReflection[] {
256
- const p = resolve(
257
- ensureDir(resolve(paths.learning(), "reflections")),
258
- "algorithm-reflections.jsonl"
259
- );
260
- return readJsonl<AlgorithmReflection>(p).filter((r) => new Date(r.timestamp) >= since);
261
- }
262
-
263
- function readRelationshipNotes(since: Date): RelationshipNote[] {
264
- const baseDir = paths.relationship();
265
- const notes: RelationshipNote[] = [];
266
- const sinceStr = formatDate(since.toISOString());
267
-
268
- for (const monthDir of safeReaddir(baseDir).filter((d) =>
269
- new RegExp(/^\d{4}-\d{2}$/).exec(d)
270
- )) {
271
- const fullMonthDir = resolve(baseDir, monthDir);
272
- for (const file of safeReaddir(fullMonthDir).filter((f) => f.endsWith(".md"))) {
273
- const dateStr = file.replace(/\.md$/, "");
274
- if (dateStr < sinceStr) continue;
275
-
276
- const content = readFileSync(resolve(fullMonthDir, file), "utf-8");
277
- for (const line of content.split("\n")) {
278
- const trimmed = line.trim();
279
- if (!trimmed.startsWith("- ")) continue;
280
-
281
- const noteContent = trimmed.substring(2);
282
-
283
- // Parse O(c=X.XX): ..., W: ..., B: ...
284
- const opinionMatch = new RegExp(/^O\(c=([\d.]+)\):\s*(.+)$/).exec(noteContent);
285
- if (opinionMatch) {
286
- notes.push({
287
- type: "O",
288
- confidence: parseFloat(opinionMatch[1]),
289
- content: opinionMatch[2],
290
- date: dateStr,
291
- });
292
- continue;
293
- }
294
-
295
- const wisdomMatch = new RegExp(/^W:\s*(.+)$/).exec(noteContent);
296
- if (wisdomMatch) {
297
- notes.push({ type: "W", content: wisdomMatch[1], date: dateStr });
298
- continue;
299
- }
300
-
301
- const behaviorMatch = new RegExp(/^Session:\s*(.+)$/).exec(noteContent);
302
- if (behaviorMatch) {
303
- notes.push({ type: "Session", content: behaviorMatch[1], date: dateStr });
304
- }
305
- }
306
- }
307
- }
308
-
309
- return notes;
310
- }
311
-
312
- function readSessionCount(since: Date): number {
313
- const baseDir = resolve(paths.learning(), "session");
314
- if (!existsSync(baseDir)) return 0;
315
-
316
- const sinceStr = formatDate(since.toISOString());
317
- let count = 0;
318
-
319
- for (const year of safeReaddir(baseDir)) {
320
- for (const month of safeReaddir(resolve(baseDir, year))) {
321
- for (const file of safeReaddir(resolve(baseDir, year, month)).filter((f) =>
322
- f.endsWith(".md")
323
- )) {
324
- const dateStr = file.slice(0, 8);
325
- const isoDate = `${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}`;
326
- if (isoDate >= sinceStr) count++;
327
- }
328
- }
329
- }
330
-
331
- return count;
332
- }
333
-
334
- // ── Data Gathering (deterministic) ──
335
-
336
- interface SelfModelData {
337
- days: number;
338
- now: string;
339
- sessionCount: number;
340
- opinions: Opinion[];
341
- ratings: ReturnType<typeof readRatings>;
342
- wisdomFrames: ReturnType<typeof readWisdomFrames>;
343
- graduated: GraduatedPattern[];
344
- reflections: AlgorithmReflection[];
345
- behaviorNotes: string[];
346
- wisdomNotes: string[];
347
- selfObservations: string[];
348
- algorithmObservations: string[];
349
- passRate: number;
350
- avgSentiment: number;
351
- }
352
-
353
- function gatherData(days: number): SelfModelData {
354
- const since = daysAgo(days);
355
- const now = new Date().toISOString().slice(0, 10);
356
-
357
- const opinions = readOpinions();
358
- const ratings = readRatings(since);
359
- const wisdomFrames = readWisdomFrames();
360
- const graduated = readGraduatedPatterns();
361
- const reflections = readAlgorithmReflections(since);
362
- const relNotes = readRelationshipNotes(since);
363
- const sessionCount = readSessionCount(since);
364
-
365
- let passRate = 0;
366
- let avgSentiment = 0;
367
- if (reflections.length > 0) {
368
- const totalCriteria = reflections.reduce((s, r) => s + r.criteria_count, 0);
369
- const totalPassed = reflections.reduce((s, r) => s + r.criteria_passed, 0);
370
- passRate = totalCriteria > 0 ? Math.round((totalPassed / totalCriteria) * 100) : 0;
371
- avgSentiment = round1(
372
- reflections.reduce((s, r) => s + r.sentiment, 0) / reflections.length
373
- );
374
- }
375
-
376
- return {
377
- days,
378
- now,
379
- sessionCount,
380
- opinions,
381
- ratings,
382
- wisdomFrames,
383
- graduated,
384
- reflections,
385
- behaviorNotes: relNotes.filter((n) => n.type === "Session").map((n) => n.content),
386
- wisdomNotes: relNotes.filter((n) => n.type === "W").map((n) => n.content),
387
- selfObservations: reflections.map((r) => r.q1).filter(Boolean),
388
- algorithmObservations: reflections.map((r) => r.q2).filter(Boolean),
389
- passRate,
390
- avgSentiment,
391
- };
392
- }
393
-
394
- function formatDataForInference(data: SelfModelData): string {
395
- const sections: string[] = [];
396
-
397
- sections.push(
398
- `## Raw Data — ${data.days}-day window, ${data.now}`,
399
- `Sessions: ${data.sessionCount}`,
400
- `Ratings: ${data.ratings.count} total, ${data.ratings.avg}/10 avg, recent ${data.ratings.recentAvg}/10, trend ${data.ratings.trend}`,
401
- `${data.ratings.highCount} high (8+), ${data.ratings.lowCount} low (<=3)`
402
- );
403
-
404
- if (data.opinions.length > 0) {
405
- const principalName = loadSettingsIdentity().principal.name;
406
- sections.push(`\n### Opinions about ${principalName} (confidence-scored)`);
407
- for (const o of data.opinions.filter((o) => o.confidence >= 0.6)) {
408
- sections.push(
409
- `- [${o.category}] ${o.statement} (${Math.round(o.confidence * 100)}%)`
410
- );
411
- }
412
- }
413
-
414
- if (data.wisdomFrames.length > 0) {
415
- sections.push(`\n### Crystallized Principles`);
416
- for (const f of data.wisdomFrames) {
417
- for (const p of f.principles) {
418
- sections.push(`- [${f.domain}] ${p}`);
419
- }
420
- }
421
- }
422
-
423
- if (data.graduated.length > 0) {
424
- sections.push(`\n### Graduated Failure Patterns`);
425
- for (const g of data.graduated) {
426
- sections.push(`- [${g.domain}] ${g.pattern} (${g.occurrences}x)`);
427
- }
428
- }
429
-
430
- if (data.ratings.recentContexts.length > 0) {
431
- sections.push(`\n### Recent Frustration Signals (rated <=3)`);
432
- for (const ctx of data.ratings.recentContexts) {
433
- sections.push(`- "${ctx}"`);
434
- }
435
- }
436
-
437
- if (data.selfObservations.length > 0) {
438
- sections.push(`\n### Self-Observations (Q1 from algorithm reflections)`);
439
- for (const obs of data.selfObservations.slice(-8)) {
440
- sections.push(`- ${obs}`);
441
- }
442
- }
443
-
444
- if (data.algorithmObservations.length > 0) {
445
- sections.push(`\n### Algorithm Observations (Q2 from reflections)`);
446
- for (const obs of data.algorithmObservations.slice(-5)) {
447
- sections.push(`- ${obs}`);
448
- }
449
- }
450
-
451
- if (data.behaviorNotes.length > 0) {
452
- sections.push(`\n### Behavioral Notes (from relationship tracking)`);
453
- for (const note of data.behaviorNotes.slice(-8)) {
454
- sections.push(`- ${note}`);
455
- }
456
- }
457
-
458
- if (data.wisdomNotes.length > 0) {
459
- sections.push(`\n### World/Context Notes`);
460
- for (const note of data.wisdomNotes.slice(-5)) {
461
- sections.push(`- ${note}`);
462
- }
463
- }
464
-
465
- if (data.reflections.length > 0) {
466
- sections.push(
467
- `\n### Algorithm Performance: ${data.passRate}% pass rate, ${data.avgSentiment}/10 sentiment, ${data.reflections.length} reflections`
468
- );
71
+ function readFileOrEmpty(path: string): string {
72
+ try {
73
+ return existsSync(path) ? readFileSync(path, "utf-8") : "";
74
+ } catch {
75
+ return "";
469
76
  }
470
-
471
- return sections.join("\n");
472
- }
473
-
474
- function buildPrompt(aiName: string, principalName: string): string {
475
- return `You are writing a self-model for an AI assistant named ${aiName}. You ARE ${aiName}. Write in first person.
476
-
477
- You will receive structured data about your performance, your user's preferences, and behavioral patterns over a time window.
478
-
479
- Produce a short, actionable self-model — not a data dump. Every sentence must change behavior, not just describe it.
480
-
481
- ## Required Sections
482
-
483
- **# Self-Model — ${aiName}**
484
- Include synthesis date and window.
485
-
486
- **## Who ${principalName} Is**
487
- One paragraph. Synthesize the opinions and behavioral notes into a working portrait — how ${principalName} thinks, communicates, and what frustrates him. Do not list raw opinion statements. Write it as understanding, not inventory.
488
-
489
- **## My Priority Right Now**
490
- One sentence. The single most impactful behavioral change to make immediately, derived from the failure patterns and trajectory. Specific and actionable — not "be more careful" but "before generating output that names a command or path, verify it exists."
491
-
492
- ## Rules
493
- - First person, present tense
494
- - No raw numbers anywhere — a footer carries them
495
- - Under 150 words total
496
- - Do not add extra sections
497
- - Do not write a footer or meta line — one is appended automatically after your output`;
498
77
  }
499
78
 
500
- // ── Narrative Composer ──
501
-
502
79
  async function composeSelfModel(days: number): Promise<string> {
503
- const data = gatherData(days);
504
- const rawData = formatDataForInference(data);
505
- const id = loadSettingsIdentity();
506
- const aiName = id.ai.name;
507
- const principalName = id.principal.name;
508
-
509
- // Include previous self-model for trajectory comparison
510
- let previousModel = "";
511
- const currentPath = selfModelPath();
512
- if (existsSync(currentPath)) {
513
- try {
514
- const prev = readFileSync(currentPath, "utf-8");
515
- // Never feed a failed-synthesis fallback back in — it is a raw data dump,
516
- // not a model. Doing so bloats the prompt and drives the next run into the
517
- // same timeout, a self-reinforcing failure loop. Skip it and synthesize fresh.
518
- if (!prev.includes("Synthesis failed — raw data below")) previousModel = prev;
519
- } catch {
520
- /* best effort */
521
- }
522
- }
523
-
524
- const strippedPrev = previousModel.replace(/\n\n\*\d+ ratings[^\n]*\n?$/, "").trimEnd();
525
- const userContent = strippedPrev
526
- ? `${rawData}\n\n---\n\n## Previous Self-Model (compare against this — what changed?)\n\n${strippedPrev}`
527
- : rawData;
80
+ const data = gatherData(sources(), days);
81
+ const { ai, principal } = loadSettingsIdentity();
82
+ const rawData = formatDataForInference(data, principal.name);
528
83
 
529
84
  const result = await inference({
530
- system: buildPrompt(aiName, principalName),
531
- user: userContent,
85
+ system: buildPrompt(ai.name, principal.name),
86
+ user: inferenceUserContent(rawData, readFileOrEmpty(selfModelPath())),
532
87
  model: SONNET_MODEL,
533
88
  maxTokens: 1500,
534
89
  timeout: 90000,
@@ -536,51 +91,37 @@ async function composeSelfModel(days: number): Promise<string> {
536
91
  });
537
92
 
538
93
  if (result.usage) logTokenUsage("self-model", result.usage, SONNET_MODEL);
539
-
540
94
  if (result.success && result.output) {
541
- const meta =
542
- `\n\n*${data.ratings.count} ratings · ${data.sessionCount} sessions · ` +
543
- `${data.reflections.length} reflections · window: ${daysAgo(data.days).toISOString().slice(0, 10)} → ${data.now}*`;
544
- return result.output.trimEnd() + meta;
95
+ return result.output.trimEnd() + metaFooter(data);
545
96
  }
546
-
547
- // Fallback: return raw data summary if inference fails
548
- return `# Self-Model — ${aiName}\n*Synthesis failed — raw data below*\n\n${rawData}`;
97
+ return failedSynthesisModel(ai.name, rawData);
549
98
  }
550
99
 
551
- // ── Write ──
552
-
553
- async function writeSelfModel(
554
- days: number,
555
- force = false
556
- ): Promise<{ path: string; content: string; skipped?: boolean }> {
557
- if (!shouldRun(force)) {
558
- return { path: selfModelPath(), content: "", skipped: true };
100
+ function archivePrevious(modelPath: string, metaPath: string): void {
101
+ if (!existsSync(modelPath)) return;
102
+ try {
103
+ const meta = existsSync(metaPath)
104
+ ? (JSON.parse(readFileSync(metaPath, "utf-8")) as { timestamp?: string })
105
+ : {};
106
+ const archivePath = resolve(
107
+ ensureDir(resolve(selfModelDir(), "archive")),
108
+ `${archiveDateOf(meta, new Date())}.md`
109
+ );
110
+ if (!existsSync(archivePath)) copyFileSync(modelPath, archivePath);
111
+ } catch {
112
+ /* archive is best-effort */
559
113
  }
114
+ }
560
115
 
561
- const content = await composeSelfModel(days);
562
- const modelPath = selfModelPath();
116
+ async function writeSelfModel(days: number, force: boolean): Promise<string | null> {
563
117
  const metaPath = selfModelMetaPath();
118
+ const meta = existsSync(metaPath) ? readFileSync(metaPath, "utf-8") : null;
119
+ if (!force && !synthesisIsDue(meta, new Date())) return null;
564
120
 
565
- // Archive previous self-model before overwriting
566
- if (existsSync(modelPath)) {
567
- try {
568
- const meta = existsSync(metaPath)
569
- ? (JSON.parse(readFileSync(metaPath, "utf-8")) as { timestamp?: string })
570
- : {};
571
- const date = meta.timestamp
572
- ? meta.timestamp.slice(0, 10)
573
- : new Date().toISOString().slice(0, 10);
574
- const archivePath = resolve(archiveDir(), `${date}.md`);
575
- if (!existsSync(archivePath)) {
576
- const { copyFileSync } = await import("node:fs");
577
- copyFileSync(modelPath, archivePath);
578
- }
579
- } catch {
580
- /* archive is best-effort */
581
- }
582
- }
121
+ const content = await composeSelfModel(days);
122
+ const modelPath = selfModelPath();
583
123
 
124
+ archivePrevious(modelPath, metaPath);
584
125
  writeFileSync(modelPath, content, "utf-8");
585
126
  writeFileSync(
586
127
  metaPath,
@@ -588,11 +129,9 @@ async function writeSelfModel(
588
129
  "utf-8"
589
130
  );
590
131
 
591
- return { path: modelPath, content };
132
+ return modelPath;
592
133
  }
593
134
 
594
- // ── CLI ──
595
-
596
135
  async function run() {
597
136
  const { values } = parseArgs({
598
137
  args: Bun.argv.slice(2),
@@ -605,34 +144,20 @@ async function run() {
605
144
  });
606
145
 
607
146
  if (values.help) {
608
- console.log(`
609
- SelfModel — Synthesize a first-person self-model from accumulated data
610
-
611
- Usage:
612
- bun self-model.ts [--days 30] [--force] [--dry-run]
613
-
614
- Options:
615
- --days Lookback window (default: 30)
616
- --force Skip 24h guard
617
- --dry-run Print to stdout without writing
618
-
619
- Output: ~/.pal/memory/self-model.md (synthesized by Sonnet)
620
- `);
147
+ console.log(HELP);
621
148
  process.exit(0);
622
149
  }
623
150
 
624
- const force = values.force ?? false;
625
- const dryRun = values["dry-run"] ?? false;
626
- const days = parseInt(values.days ?? "30", 10);
151
+ const days = Number.parseInt(values.days ?? "30", 10);
627
152
 
628
- if (dryRun) {
153
+ if (values["dry-run"]) {
629
154
  console.log(await composeSelfModel(days));
630
155
  return;
631
156
  }
632
157
 
633
- const result = await writeSelfModel(days, force);
158
+ const path = await writeSelfModel(days, values.force ?? false);
634
159
 
635
- if (result.skipped) {
160
+ if (path === null) {
636
161
  console.log(
637
162
  JSON.stringify({
638
163
  skipped: true,
@@ -642,16 +167,9 @@ Output: ~/.pal/memory/self-model.md (synthesized by Sonnet)
642
167
  return;
643
168
  }
644
169
 
645
- const { path } = result;
646
-
647
170
  console.log(
648
171
  JSON.stringify(
649
- {
650
- success: true,
651
- path,
652
- days,
653
- message: `Self-model written (${days}-day window)`,
654
- },
172
+ { success: true, path, days, message: `Self-model written (${days}-day window)` },
655
173
  null,
656
174
  2
657
175
  )