portable-agent-layer 0.70.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 (81) hide show
  1. package/README.md +5 -1
  2. package/assets/schema/pal-settings.schema.json +4 -0
  3. package/assets/skills/onboarding/SKILL.md +109 -0
  4. package/assets/skills/projects/SKILL.md +11 -2
  5. package/assets/templates/pal-settings.json +1 -0
  6. package/package.json +5 -1
  7. package/src/cli/index.ts +39 -12
  8. package/src/cli/migrate.ts +1 -1
  9. package/src/cli/personal-context.ts +67 -0
  10. package/src/cli/server.ts +13 -7
  11. package/src/cli/setup-identity.ts +13 -1
  12. package/src/cli/skill.ts +1 -1
  13. package/src/hooks/CompactRecover.ts +28 -86
  14. package/src/hooks/LedgerUnapplied.ts +3 -28
  15. package/src/hooks/LoadContext.ts +33 -60
  16. package/src/hooks/SecurityValidator.ts +16 -109
  17. package/src/hooks/handlers/agenda.ts +223 -0
  18. package/src/hooks/handlers/failure-principle.ts +19 -44
  19. package/src/hooks/handlers/inject-retrieval.ts +6 -2
  20. package/src/hooks/handlers/session-intelligence.ts +13 -70
  21. package/src/hooks/lib/agenda-store.ts +41 -0
  22. package/src/hooks/lib/capture-store.ts +103 -0
  23. package/src/hooks/lib/compact-recall.ts +89 -0
  24. package/src/hooks/lib/failure-principle.ts +98 -0
  25. package/src/hooks/lib/ledger-hook.ts +35 -0
  26. package/src/hooks/lib/ledger.ts +48 -1
  27. package/src/hooks/lib/paths.ts +0 -1
  28. package/src/hooks/lib/projects.ts +16 -1
  29. package/src/hooks/lib/security-gate.ts +159 -0
  30. package/src/hooks/lib/serves.ts +60 -0
  31. package/src/hooks/lib/session-context.ts +74 -0
  32. package/src/hooks/lib/stop.ts +14 -0
  33. package/src/hooks/lib/telos-goals.ts +144 -0
  34. package/src/hooks/lib/telos-topics.ts +68 -0
  35. package/src/hooks/lib/token-usage.ts +3 -1
  36. package/src/hooks/lib/wall-clock.ts +58 -0
  37. package/src/tools/agent/algorithm-reflect.ts +28 -97
  38. package/src/tools/agent/analyze.ts +19 -120
  39. package/src/tools/agent/handoff-note.ts +40 -70
  40. package/src/tools/agent/project.ts +47 -136
  41. package/src/tools/agent/relationship-note.ts +27 -46
  42. package/src/tools/agent/synthesize.ts +1 -1
  43. package/src/tools/agent/thread.ts +43 -123
  44. package/src/tools/control-room/data.ts +332 -0
  45. package/src/tools/control-room/matrix.ts +182 -0
  46. package/src/tools/control-room/server.ts +150 -0
  47. package/src/tools/control-room/ui/agenda.tsx +43 -0
  48. package/src/tools/control-room/ui/agents.tsx +67 -0
  49. package/src/tools/control-room/ui/app.css +857 -0
  50. package/src/tools/control-room/ui/app.tsx +74 -0
  51. package/src/tools/control-room/ui/board.tsx +82 -0
  52. package/src/tools/control-room/ui/format.ts +31 -0
  53. package/src/tools/control-room/ui/handoffs.tsx +37 -0
  54. package/src/tools/control-room/ui/index.html +19 -0
  55. package/src/tools/control-room/ui/ledger.tsx +137 -0
  56. package/src/tools/control-room/ui/matrix.tsx +117 -0
  57. package/src/tools/control-room/ui/panel.tsx +60 -0
  58. package/src/tools/control-room/ui/signal.tsx +161 -0
  59. package/src/tools/ledger/view.ts +3 -0
  60. package/src/tools/lib/algorithm-reflect.ts +84 -0
  61. package/src/tools/lib/analyze-report.ts +120 -0
  62. package/src/tools/lib/handoff-note.ts +88 -0
  63. package/src/tools/lib/note-flags.ts +59 -0
  64. package/src/tools/lib/project-isc.ts +151 -0
  65. package/src/tools/lib/relationship-reflect.ts +402 -0
  66. package/src/tools/lib/self-model.ts +499 -0
  67. package/src/tools/lib/session-usage.ts +216 -0
  68. package/src/tools/lib/skill-doctor.ts +457 -0
  69. package/src/tools/lib/thread.ts +119 -0
  70. package/src/tools/lib/token-report.ts +173 -0
  71. package/src/tools/lib/transcript-usage.ts +42 -0
  72. package/src/tools/lib/usage-buckets.ts +329 -0
  73. package/src/tools/relationship-reflect.ts +48 -412
  74. package/src/tools/self-model.ts +76 -558
  75. package/src/tools/session-summary.ts +8 -215
  76. package/src/tools/skill-doctor.ts +9 -444
  77. package/src/tools/token-cost.ts +18 -428
  78. package/assets/templates/ledger-page.html +0 -213
  79. package/src/cli/setup-telos.ts +0 -52
  80. package/src/hooks/lib/setup.ts +0 -60
  81. package/src/tools/ledger/server.ts +0 -111
@@ -0,0 +1,402 @@
1
+ /**
2
+ * Periodic reflection on relationship notes: what recurs, what it says about
3
+ * the user, and which of it has earned a tracked opinion.
4
+ *
5
+ * The tool around this is only ever spawned, so none of the judgement was
6
+ * reachable from a test — not the note grammar, not the similarity grouping
7
+ * that decides two notes are the same observation, not the rule that a note
8
+ * needs a second sighting before it becomes an opinion.
9
+ *
10
+ * The readers take the directory to read and the clock to read it against,
11
+ * which is what makes a fixed corpus at a fixed date assertable.
12
+ */
13
+
14
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
15
+ import { resolve } from "node:path";
16
+ import { addEvidence, createOpinion, findSimilarOpinion } from "../../hooks/lib/opinions";
17
+ import { similarity } from "../../hooks/lib/text-similarity";
18
+
19
+ type Opinion = ReturnType<typeof createOpinion>;
20
+
21
+ export interface Rating {
22
+ ts: string;
23
+ rating: number;
24
+ context: string;
25
+ source: "explicit" | "implicit";
26
+ }
27
+
28
+ export interface ParsedNote {
29
+ type: "W" | "O" | "Session";
30
+ text: string;
31
+ confidence?: number;
32
+ date: string;
33
+ time: string;
34
+ }
35
+
36
+ export interface OpinionChange {
37
+ statement: string;
38
+ action: "created" | "strengthened";
39
+ oldConfidence?: number;
40
+ newConfidence: number;
41
+ }
42
+
43
+ export interface OpinionSummary {
44
+ text: string;
45
+ occurrences: number;
46
+ avgConfidence: number;
47
+ dates: string[];
48
+ }
49
+
50
+ /** What reflection decided to do, kept separate from doing it. */
51
+ export interface PromotionPlan {
52
+ changes: OpinionChange[];
53
+ toSave: Opinion[];
54
+ }
55
+
56
+ /** Two notes count as the same observation from here up. */
57
+ const SAME_OBSERVATION = 0.3;
58
+
59
+ /** A note needs a second sighting before it earns an opinion of its own. */
60
+ const SIGHTINGS_TO_PROMOTE = 2;
61
+
62
+ const EVIDENCE_CHARS = 120;
63
+ const LOW_RATING = 4;
64
+ const HIGH_RATING = 7;
65
+ const HIGH_CONFIDENCE = 0.85;
66
+ const MONTH_DIR = /^\d{4}-\d{2}$/;
67
+ const TIME_HEADING = /^## (\d{2}:\d{2})/;
68
+ const SCORED_NOTE = /^- ([OB])\(c=([\d.]+)\):\s*(.+)$/;
69
+ const WORLD_NOTE = /^- W:\s*(.+)$/;
70
+
71
+ export function cutoffDate(daysBack: number, now: Date): Date {
72
+ const cutoff = new Date(now);
73
+ cutoff.setDate(cutoff.getDate() - daysBack);
74
+ return cutoff;
75
+ }
76
+
77
+ /**
78
+ * One day's note file. A time heading applies to every note under it, so the
79
+ * heading is carried forward rather than re-read per note.
80
+ */
81
+ export function parseNoteFile(content: string, date: string): ParsedNote[] {
82
+ const notes: ParsedNote[] = [];
83
+ let time = "";
84
+
85
+ for (const line of content.split("\n")) {
86
+ const heading = TIME_HEADING.exec(line);
87
+ if (heading) {
88
+ time = heading[1];
89
+ continue;
90
+ }
91
+
92
+ const scored = SCORED_NOTE.exec(line);
93
+ if (scored) {
94
+ notes.push({
95
+ type: scored[1] === "O" ? "O" : "Session",
96
+ confidence: Number.parseFloat(scored[2]),
97
+ text: scored[3],
98
+ date,
99
+ time,
100
+ });
101
+ continue;
102
+ }
103
+
104
+ const world = WORLD_NOTE.exec(line);
105
+ if (world) notes.push({ type: "W", text: world[1], date, time });
106
+ }
107
+
108
+ return notes;
109
+ }
110
+
111
+ export function loadNotes(
112
+ relationshipDir: string,
113
+ daysBack: number,
114
+ now: Date = new Date()
115
+ ): ParsedNote[] {
116
+ if (!existsSync(relationshipDir)) return [];
117
+
118
+ const cutoff = cutoffDate(daysBack, now);
119
+ const notes: ParsedNote[] = [];
120
+
121
+ for (const monthDir of readdirSync(relationshipDir).sort().reverse()) {
122
+ if (!MONTH_DIR.test(monthDir)) continue;
123
+ const monthPath = resolve(relationshipDir, monthDir);
124
+
125
+ let files: string[];
126
+ try {
127
+ files = readdirSync(monthPath)
128
+ .filter((f) => f.endsWith(".md"))
129
+ .sort()
130
+ .reverse();
131
+ } catch {
132
+ continue;
133
+ }
134
+
135
+ for (const file of files) {
136
+ const date = file.replace(".md", "");
137
+ if (new Date(date) < cutoff) continue;
138
+ try {
139
+ notes.push(
140
+ ...parseNoteFile(readFileSync(resolve(monthPath, file), "utf-8"), date)
141
+ );
142
+ } catch {
143
+ /* unreadable day */
144
+ }
145
+ }
146
+ }
147
+
148
+ return notes;
149
+ }
150
+
151
+ export function parseRatings(content: string, daysBack: number, now: Date): Rating[] {
152
+ const cutoff = cutoffDate(daysBack, now).getTime();
153
+
154
+ return content
155
+ .split("\n")
156
+ .filter((line) => line.trim())
157
+ .map((line) => {
158
+ try {
159
+ return JSON.parse(line) as Rating;
160
+ } catch {
161
+ return null;
162
+ }
163
+ })
164
+ .filter((r): r is Rating => r !== null && new Date(r.ts).getTime() >= cutoff);
165
+ }
166
+
167
+ export function loadRatings(
168
+ ratingsFile: string,
169
+ daysBack: number,
170
+ now: Date = new Date()
171
+ ): Rating[] {
172
+ if (!existsSync(ratingsFile)) return [];
173
+ return parseRatings(readFileSync(ratingsFile, "utf-8"), daysBack, now);
174
+ }
175
+
176
+ /**
177
+ * Collapse notes saying the same thing into one group, keyed by the first note
178
+ * that said it. First match wins, so a note joins the earliest group it is
179
+ * close enough to rather than the closest one.
180
+ */
181
+ function groupBySimilarity(notes: ParsedNote[]): Map<string, ParsedNote[]> {
182
+ const groups = new Map<string, ParsedNote[]>();
183
+
184
+ for (const note of notes) {
185
+ const key = [...groups.keys()].find(
186
+ (existing) => similarity(note.text, existing) >= SAME_OBSERVATION
187
+ );
188
+ if (key === undefined) groups.set(note.text, [note]);
189
+ else groups.get(key)?.push(note);
190
+ }
191
+
192
+ return groups;
193
+ }
194
+
195
+ const opinionNotesOf = (notes: ParsedNote[]) => notes.filter((n) => n.type === "O");
196
+
197
+ /**
198
+ * An observation already tracked gains evidence; one seen twice with nothing
199
+ * tracked becomes an opinion. A single unmatched sighting is left alone — it is
200
+ * an anecdote until it repeats.
201
+ */
202
+ export function planPromotions(notes: ParsedNote[], opinions: Opinion[]): PromotionPlan {
203
+ const plan: PromotionPlan = { changes: [], toSave: [] };
204
+
205
+ for (const [statement, group] of groupBySimilarity(opinionNotesOf(notes))) {
206
+ const existing = findSimilarOpinion(statement, opinions);
207
+
208
+ if (existing) {
209
+ const updated = group.reduce(
210
+ (opinion, note) =>
211
+ addEvidence(opinion, "supporting", note.text.slice(0, EVIDENCE_CHARS)),
212
+ existing
213
+ );
214
+ if (updated.confidence === existing.confidence) continue;
215
+ plan.changes.push({
216
+ statement: existing.statement,
217
+ action: "strengthened",
218
+ oldConfidence: existing.confidence,
219
+ newConfidence: updated.confidence,
220
+ });
221
+ plan.toSave.push(updated);
222
+ continue;
223
+ }
224
+
225
+ if (group.length < SIGHTINGS_TO_PROMOTE) continue;
226
+ const opinion = group
227
+ .slice(1)
228
+ .reduce(
229
+ (o, note) => addEvidence(o, "supporting", note.text.slice(0, EVIDENCE_CHARS)),
230
+ createOpinion(statement, group[0].text.slice(0, EVIDENCE_CHARS))
231
+ );
232
+ plan.changes.push({
233
+ statement,
234
+ action: "created",
235
+ newConfidence: opinion.confidence,
236
+ });
237
+ plan.toSave.push(opinion);
238
+ }
239
+
240
+ return plan;
241
+ }
242
+
243
+ export function groupNoteOccurrences(notes: ParsedNote[]): OpinionSummary[] {
244
+ return [...groupBySimilarity(opinionNotesOf(notes)).values()]
245
+ .map((group) => {
246
+ const confidences = group
247
+ .map((n) => n.confidence)
248
+ .filter((c): c is number => c !== undefined);
249
+ return {
250
+ text: group[0].text,
251
+ occurrences: group.length,
252
+ avgConfidence:
253
+ confidences.length > 0
254
+ ? confidences.reduce((a, b) => a + b, 0) / confidences.length
255
+ : 0,
256
+ dates: [...new Set(group.map((n) => n.date))],
257
+ };
258
+ })
259
+ .sort((a, b) => b.occurrences - a.occurrences);
260
+ }
261
+
262
+ export function averageRating(ratings: Rating[]): number {
263
+ if (ratings.length === 0) return 0;
264
+ return ratings.reduce((sum, r) => sum + r.rating, 0) / ratings.length;
265
+ }
266
+
267
+ const contextsOf = (ratings: Rating[]) =>
268
+ ratings
269
+ .slice(0, 3)
270
+ .map((r) => `"${r.context.slice(0, 60)}"`)
271
+ .join(", ");
272
+
273
+ export function correlateRatings(ratings: Rating[]): string[] {
274
+ const low = ratings.filter((r) => r.rating <= LOW_RATING);
275
+ const high = ratings.filter((r) => r.rating >= HIGH_RATING);
276
+ const insights: string[] = [];
277
+
278
+ if (low.length > 0) {
279
+ insights.push(
280
+ `${low.length} low ratings (<=${LOW_RATING}) — common contexts: ${contextsOf(low)}`
281
+ );
282
+ }
283
+ if (high.length > 0) {
284
+ insights.push(
285
+ `${high.length} high ratings (>=${HIGH_RATING}) — common contexts: ${contextsOf(high)}`
286
+ );
287
+ }
288
+ if (ratings.length > 0) {
289
+ const explicit = ratings.filter((r) => r.source === "explicit").length;
290
+ insights.push(
291
+ `Source mix: ${explicit} explicit, ${ratings.length - explicit} implicit`
292
+ );
293
+ }
294
+
295
+ return insights;
296
+ }
297
+
298
+ const pct = (confidence: number) => Math.round(confidence * 100);
299
+
300
+ export function changeLine(change: OpinionChange): string {
301
+ if (change.action === "created") {
302
+ return `- **NEW** (${pct(change.newConfidence)}%): ${change.statement}`;
303
+ }
304
+ const from = pct(change.oldConfidence ?? 0);
305
+ return `- **+** ${from}% → ${pct(change.newConfidence)}%: ${change.statement}`;
306
+ }
307
+
308
+ export function formatReport(
309
+ period: string,
310
+ notes: ParsedNote[],
311
+ ratings: Rating[],
312
+ changes: OpinionChange[],
313
+ now: Date = new Date()
314
+ ): string {
315
+ const lines: string[] = [
316
+ "# Relationship Reflection",
317
+ "",
318
+ `**Period:** ${period}`,
319
+ `**Generated:** ${now.toISOString().slice(0, 10)}`,
320
+ `**Notes analyzed:** ${notes.length}`,
321
+ `**Ratings analyzed:** ${ratings.length}`,
322
+ `**Average Rating:** ${averageRating(ratings).toFixed(1)}/10`,
323
+ "",
324
+ "---",
325
+ "",
326
+ ];
327
+
328
+ if (changes.length > 0) {
329
+ lines.push("## Opinion Changes", "", ...changes.map(changeLine), "");
330
+ }
331
+
332
+ const summaries = groupNoteOccurrences(notes);
333
+ if (summaries.length > 0) {
334
+ lines.push("## Recurring Opinions", "");
335
+ for (const op of summaries) {
336
+ const stats = ` Seen ${op.occurrences}x | Avg confidence: ${op.avgConfidence.toFixed(2)} | Dates: ${op.dates.join(", ")}`;
337
+ lines.push(`- **${op.text}**`, stats, "");
338
+ }
339
+ }
340
+
341
+ const facts = notes.filter((n) => n.type === "W").map((n) => n.text);
342
+ if (facts.length > 0) {
343
+ lines.push(
344
+ "## World Facts Observed",
345
+ "",
346
+ ...facts.slice(0, 10).map((f) => `- ${f}`),
347
+ ""
348
+ );
349
+ }
350
+
351
+ const insights = correlateRatings(ratings);
352
+ if (insights.length > 0) {
353
+ lines.push("## Rating Insights", "", ...insights.map((i) => `- ${i}`), "");
354
+ }
355
+
356
+ return lines.join("\n");
357
+ }
358
+
359
+ export function reportPath(
360
+ reflectionDir: string,
361
+ period: string,
362
+ now: Date = new Date()
363
+ ): string {
364
+ const date = now.toISOString().slice(0, 10);
365
+ const slug = period.toLowerCase().replace(/\s+/g, "-");
366
+ return resolve(reflectionDir, `${date}_${slug}-reflection.md`);
367
+ }
368
+
369
+ /** The same changes as the report, abbreviated for a terminal. */
370
+ export function consoleLines(
371
+ notes: ParsedNote[],
372
+ ratings: Rating[],
373
+ changes: OpinionChange[]
374
+ ): string[] {
375
+ const lines = [
376
+ `\nAverage Rating: ${averageRating(ratings).toFixed(1)}/10`,
377
+ `Observations: ${groupNoteOccurrences(notes).length} unique`,
378
+ ];
379
+
380
+ if (changes.length === 0) return [...lines, "\nNo opinion changes"];
381
+
382
+ lines.push("\nOpinion changes:");
383
+ for (const change of changes) {
384
+ const statement = change.statement.slice(0, 80);
385
+ lines.push(
386
+ change.action === "created"
387
+ ? ` + NEW (${pct(change.newConfidence)}%) ${statement}`
388
+ : ` ~ ${pct(change.oldConfidence ?? 0)}% → ${pct(change.newConfidence)}% ${statement}`
389
+ );
390
+ }
391
+ return lines;
392
+ }
393
+
394
+ /** Nothing to say unless something crossed the bar that gets it into context. */
395
+ export function highConfidenceLines(opinions: Opinion[]): string[] {
396
+ const high = opinions.filter((o) => o.confidence >= HIGH_CONFIDENCE);
397
+ if (high.length === 0) return [];
398
+ return [
399
+ "\nHigh-confidence opinions (injected into context):",
400
+ ...high.map((o) => ` [${pct(o.confidence)}%] ${o.statement.slice(0, 80)}`),
401
+ ];
402
+ }