portable-agent-layer 0.8.1 → 0.10.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.
@@ -2,10 +2,10 @@
2
2
  /**
3
3
  * RelationshipReflect — Periodic reflection on relationship patterns.
4
4
  *
5
- * Reads recent relationship notes and ratings to surface:
6
- * - Opinion confidence trends (which observations keep recurring?)
7
- * - Rating correlation (what interaction patterns correlate with low/high ratings?)
8
- * - Summary of the relationship state
5
+ * Reads recent relationship notes and ratings to:
6
+ * - Promote recurring O/B notes into tracked opinions with confidence
7
+ * - Update confidence on existing opinions via supporting evidence
8
+ * - Generate a summary report
9
9
  *
10
10
  * Usage:
11
11
  * bun run tool:reflect # Reflect on last 7 days
@@ -16,7 +16,17 @@
16
16
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
17
17
  import { resolve } from "node:path";
18
18
  import { parseArgs } from "node:util";
19
+ import {
20
+ addEvidence,
21
+ createOpinion,
22
+ findSimilarOpinion,
23
+ getLastReflectDate,
24
+ readOpinions,
25
+ saveOpinion,
26
+ setLastReflectDate,
27
+ } from "../hooks/lib/opinions";
19
28
  import { palHome } from "../hooks/lib/paths";
29
+ import { similarity } from "../hooks/lib/text-similarity";
20
30
 
21
31
  // ── Paths ──
22
32
 
@@ -34,28 +44,18 @@ interface Rating {
34
44
  }
35
45
 
36
46
  interface ParsedNote {
37
- type: "W" | "O";
47
+ type: "W" | "O" | "B";
38
48
  text: string;
39
49
  confidence?: number;
40
50
  date: string;
41
51
  time: string;
42
52
  }
43
53
 
44
- interface ReflectionResult {
45
- period: string;
46
- totalNotes: number;
47
- totalRatings: number;
48
- avgRating: number;
49
- opinions: OpinionSummary[];
50
- worldFacts: string[];
51
- ratingCorrelation: string[];
52
- }
53
-
54
- interface OpinionSummary {
55
- text: string;
56
- occurrences: number;
57
- avgConfidence: number;
58
- dates: string[];
54
+ interface OpinionChange {
55
+ statement: string;
56
+ action: "created" | "strengthened";
57
+ oldConfidence?: number;
58
+ newConfidence: number;
59
59
  }
60
60
 
61
61
  // ── Note Parsing ──
@@ -68,7 +68,7 @@ function loadNotes(daysBack: number): ParsedNote[] {
68
68
  const notes: ParsedNote[] = [];
69
69
 
70
70
  for (const monthDir of readdirSync(RELATIONSHIP_DIR).sort().reverse()) {
71
- if (monthDir === "reflections") continue;
71
+ if (!/^\d{4}-\d{2}$/.test(monthDir)) continue;
72
72
  const monthPath = resolve(RELATIONSHIP_DIR, monthDir);
73
73
 
74
74
  let files: string[];
@@ -96,13 +96,13 @@ function loadNotes(daysBack: number): ParsedNote[] {
96
96
  continue;
97
97
  }
98
98
 
99
- // O(c=0.85): text
100
- const opinionMatch = line.match(/^- O\(c=([\d.]+)\):\s*(.+)$/);
101
- if (opinionMatch) {
99
+ // O(c=0.85): text or B(c=0.85): text
100
+ const obMatch = line.match(/^- ([OB])\(c=([\d.]+)\):\s*(.+)$/);
101
+ if (obMatch) {
102
102
  notes.push({
103
- type: "O",
104
- confidence: Number.parseFloat(opinionMatch[1]),
105
- text: opinionMatch[2],
103
+ type: obMatch[1] as "O" | "B",
104
+ confidence: Number.parseFloat(obMatch[2]),
105
+ text: obMatch[3],
106
106
  date: dateStr,
107
107
  time: currentTime,
108
108
  });
@@ -152,39 +152,115 @@ function loadRatings(daysBack: number): Rating[] {
152
152
  );
153
153
  }
154
154
 
155
- // ── Analysis ──
155
+ // ── Opinion Promotion ──
156
+
157
+ function promoteToOpinions(notes: ParsedNote[], dryRun: boolean): OpinionChange[] {
158
+ const changes: OpinionChange[] = [];
159
+ const opinions = readOpinions();
160
+ const lastReflect = getLastReflectDate();
161
+
162
+ // Only O and B notes become opinions, skip already-processed notes
163
+ const opinionNotes = notes.filter(
164
+ (n) => (n.type === "O" || n.type === "B") && (!lastReflect || n.date > lastReflect)
165
+ );
166
+
167
+ // Group similar notes together
168
+ const groups = new Map<string, ParsedNote[]>();
169
+ for (const note of opinionNotes) {
170
+ let matched = false;
171
+ for (const [key, group] of groups) {
172
+ if (similarity(note.text, key) >= 0.3) {
173
+ group.push(note);
174
+ matched = true;
175
+ break;
176
+ }
177
+ }
178
+ if (!matched) {
179
+ groups.set(note.text, [note]);
180
+ }
181
+ }
182
+
183
+ for (const [representative, group] of groups) {
184
+ // Check against existing opinions
185
+ const existing = findSimilarOpinion(representative, opinions);
186
+
187
+ if (existing) {
188
+ // Add supporting evidence for each new note
189
+ let updated = existing;
190
+ for (const note of group) {
191
+ updated = addEvidence(updated, "supporting", note.text.slice(0, 120));
192
+ }
156
193
 
157
- function groupOpinions(notes: ParsedNote[]): OpinionSummary[] {
158
- const opinions = notes.filter((n) => n.type === "O");
159
- const groups = new Map<string, { confidences: number[]; dates: string[] }>();
160
-
161
- for (const op of opinions) {
162
- // Normalize text for grouping (lowercase, trim)
163
- const key = op.text.toLowerCase().slice(0, 100);
164
- const existing = groups.get(key) ?? { confidences: [], dates: [] };
165
- if (op.confidence !== undefined) existing.confidences.push(op.confidence);
166
- existing.dates.push(op.date);
167
- groups.set(key, existing);
194
+ if (updated.confidence !== existing.confidence) {
195
+ changes.push({
196
+ statement: existing.statement,
197
+ action: "strengthened",
198
+ oldConfidence: existing.confidence,
199
+ newConfidence: updated.confidence,
200
+ });
201
+ if (!dryRun) saveOpinion(updated);
202
+ }
203
+ } else if (group.length >= 2) {
204
+ // New opinion — requires at least 2 occurrences
205
+ const opinion = createOpinion(representative, `${group.length}x in reflect period`);
206
+ changes.push({
207
+ statement: representative,
208
+ action: "created",
209
+ newConfidence: opinion.confidence,
210
+ });
211
+ if (!dryRun) saveOpinion(opinion);
212
+ }
168
213
  }
169
214
 
170
- const summaries: OpinionSummary[] = [];
171
- for (const [, data] of groups) {
172
- const originalNote = opinions.find(
173
- (n) => n.text.toLowerCase().slice(0, 100) === [...groups.keys()][summaries.length]
174
- );
175
- const avgConf =
176
- data.confidences.length > 0
177
- ? data.confidences.reduce((a, b) => a + b, 0) / data.confidences.length
178
- : 0;
179
- summaries.push({
180
- text: originalNote?.text ?? "",
181
- occurrences: data.dates.length,
182
- avgConfidence: avgConf,
183
- dates: [...new Set(data.dates)],
184
- });
215
+ return changes;
216
+ }
217
+
218
+ // ── Analysis ──
219
+
220
+ interface OpinionSummary {
221
+ text: string;
222
+ occurrences: number;
223
+ avgConfidence: number;
224
+ dates: string[];
225
+ }
226
+
227
+ function groupNoteOccurrences(notes: ParsedNote[]): OpinionSummary[] {
228
+ const opNotes = notes.filter((n) => n.type === "O" || n.type === "B");
229
+ const groups = new Map<
230
+ string,
231
+ { confidences: number[]; dates: string[]; text: string }
232
+ >();
233
+
234
+ for (const note of opNotes) {
235
+ let matched = false;
236
+ for (const [key, group] of groups) {
237
+ if (similarity(note.text, key) >= 0.3) {
238
+ if (note.confidence !== undefined) group.confidences.push(note.confidence);
239
+ group.dates.push(note.date);
240
+ matched = true;
241
+ break;
242
+ }
243
+ }
244
+ if (!matched) {
245
+ groups.set(note.text, {
246
+ text: note.text,
247
+ confidences: note.confidence !== undefined ? [note.confidence] : [],
248
+ dates: [note.date],
249
+ });
250
+ }
185
251
  }
186
252
 
187
- return summaries.sort((a, b) => b.occurrences - a.occurrences);
253
+ return [...groups.values()]
254
+ .map((g) => ({
255
+ text: g.text,
256
+ occurrences: g.dates.length,
257
+ avgConfidence:
258
+ g.confidences.length > 0
259
+ ? g.confidences.reduce((a, b) => a + b, 0) / g.confidences.length
260
+ : 0,
261
+ dates: [...new Set(g.dates)],
262
+ }))
263
+ .sort((a, b) => b.occurrences - a.occurrences);
188
264
  }
189
265
 
190
266
  function correlateRatings(ratings: Rating[]): string[] {
@@ -219,48 +295,53 @@ function correlateRatings(ratings: Rating[]): string[] {
219
295
  return correlations;
220
296
  }
221
297
 
222
- function analyze(
298
+ // ── Report ──
299
+
300
+ function formatReport(
301
+ period: string,
223
302
  notes: ParsedNote[],
224
303
  ratings: Rating[],
225
- period: string
226
- ): ReflectionResult {
304
+ opinionChanges: OpinionChange[]
305
+ ): string {
306
+ const date = new Date().toISOString().slice(0, 10);
227
307
  const avgRating =
228
308
  ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : 0;
309
+ const summaries = groupNoteOccurrences(notes);
310
+ const worldFacts = notes.filter((n) => n.type === "W").map((n) => n.text);
311
+ const ratingInsights = correlateRatings(ratings);
229
312
 
230
- return {
231
- period,
232
- totalNotes: notes.length,
233
- totalRatings: ratings.length,
234
- avgRating,
235
- opinions: groupOpinions(notes),
236
- worldFacts: notes
237
- .filter((n) => n.type === "W")
238
- .map((n) => n.text)
239
- .slice(0, 10),
240
- ratingCorrelation: correlateRatings(ratings),
241
- };
242
- }
243
-
244
- // ── Report ──
245
-
246
- function formatReport(result: ReflectionResult): string {
247
- const date = new Date().toISOString().slice(0, 10);
248
313
  const lines: string[] = [
249
314
  "# Relationship Reflection",
250
315
  "",
251
- `**Period:** ${result.period}`,
316
+ `**Period:** ${period}`,
252
317
  `**Generated:** ${date}`,
253
- `**Notes analyzed:** ${result.totalNotes}`,
254
- `**Ratings analyzed:** ${result.totalRatings}`,
255
- `**Average Rating:** ${result.avgRating.toFixed(1)}/10`,
318
+ `**Notes analyzed:** ${notes.length}`,
319
+ `**Ratings analyzed:** ${ratings.length}`,
320
+ `**Average Rating:** ${avgRating.toFixed(1)}/10`,
256
321
  "",
257
322
  "---",
258
323
  "",
259
324
  ];
260
325
 
261
- if (result.opinions.length > 0) {
326
+ if (opinionChanges.length > 0) {
327
+ lines.push("## Opinion Changes", "");
328
+ for (const change of opinionChanges) {
329
+ if (change.action === "created") {
330
+ lines.push(
331
+ `- **NEW** (${Math.round(change.newConfidence * 100)}%): ${change.statement}`
332
+ );
333
+ } else {
334
+ lines.push(
335
+ `- **+** ${Math.round(change.oldConfidence ?? 0 * 100)}% → ${Math.round(change.newConfidence * 100)}%: ${change.statement}`
336
+ );
337
+ }
338
+ }
339
+ lines.push("");
340
+ }
341
+
342
+ if (summaries.length > 0) {
262
343
  lines.push("## Recurring Opinions", "");
263
- for (const op of result.opinions) {
344
+ for (const op of summaries) {
264
345
  lines.push(
265
346
  `- **${op.text}**`,
266
347
  ` Seen ${op.occurrences}x | Avg confidence: ${op.avgConfidence.toFixed(2)} | Dates: ${op.dates.join(", ")}`,
@@ -269,17 +350,17 @@ function formatReport(result: ReflectionResult): string {
269
350
  }
270
351
  }
271
352
 
272
- if (result.worldFacts.length > 0) {
353
+ if (worldFacts.length > 0) {
273
354
  lines.push("## World Facts Observed", "");
274
- for (const fact of result.worldFacts) {
355
+ for (const fact of worldFacts.slice(0, 10)) {
275
356
  lines.push(`- ${fact}`);
276
357
  }
277
358
  lines.push("");
278
359
  }
279
360
 
280
- if (result.ratingCorrelation.length > 0) {
361
+ if (ratingInsights.length > 0) {
281
362
  lines.push("## Rating Insights", "");
282
- for (const c of result.ratingCorrelation) {
363
+ for (const c of ratingInsights) {
283
364
  lines.push(`- ${c}`);
284
365
  }
285
366
  lines.push("");
@@ -288,7 +369,7 @@ function formatReport(result: ReflectionResult): string {
288
369
  return lines.join("\n");
289
370
  }
290
371
 
291
- function writeReport(result: ReflectionResult, period: string): string {
372
+ function writeReport(report: string, period: string): string {
292
373
  if (!existsSync(REFLECTION_DIR)) mkdirSync(REFLECTION_DIR, { recursive: true });
293
374
 
294
375
  const date = new Date().toISOString().slice(0, 10);
@@ -296,7 +377,7 @@ function writeReport(result: ReflectionResult, period: string): string {
296
377
  const filename = `${date}_${slug}-reflection.md`;
297
378
  const filepath = resolve(REFLECTION_DIR, filename);
298
379
 
299
- writeFileSync(filepath, formatReport(result), "utf-8");
380
+ writeFileSync(filepath, report, "utf-8");
300
381
  return filepath;
301
382
  }
302
383
 
@@ -313,50 +394,79 @@ const { values } = parseArgs({
313
394
 
314
395
  if (values.help) {
315
396
  console.log(`
316
- RelationshipReflect — Periodic reflection on relationship patterns
397
+ RelationshipReflect — Periodic reflection + opinion promotion
398
+
399
+ Reads recent relationship notes and ratings. Promotes recurring
400
+ observations (O/B types) into tracked opinions with confidence scoring.
317
401
 
318
402
  Usage:
319
403
  bun run tool:reflect Reflect on last 7 days (default)
320
404
  bun run tool:reflect -- --month Reflect on last 30 days
321
405
  bun run tool:reflect -- --dry-run Preview without writing
322
406
 
323
- Output: Creates reflection report in memory/relationship/reflections/
407
+ Output:
408
+ - Updates memory/relationship/opinions.json (confidence tracking)
409
+ - Creates reflection report in memory/relationship/reflections/
324
410
  `);
325
411
  process.exit(0);
326
412
  }
327
413
 
328
414
  const daysBack = values.month ? 30 : 7;
329
415
  const period = values.month ? "Monthly" : "Weekly";
416
+ const dryRun = values["dry-run"] ?? false;
330
417
 
331
418
  const notes = loadNotes(daysBack);
332
419
  const ratings = loadRatings(daysBack);
333
420
 
334
- console.log(`Loaded ${notes.length} relationship notes from last ${daysBack} days`);
335
- console.log(`Loaded ${ratings.length} ratings from last ${daysBack} days`);
421
+ console.log(`Loaded ${notes.length} notes from last ${daysBack} days`);
422
+ console.log(`Loaded ${ratings.length} ratings`);
336
423
 
337
424
  if (notes.length === 0 && ratings.length === 0) {
338
425
  console.log("No data to analyze");
339
426
  process.exit(0);
340
427
  }
341
428
 
342
- const result = analyze(notes, ratings, period);
429
+ // Promote notes to opinions
430
+ const opinionChanges = promoteToOpinions(notes, dryRun);
343
431
 
344
- console.log(`\nAverage Rating: ${result.avgRating.toFixed(1)}/10`);
345
- console.log(`Opinions tracked: ${result.opinions.length}`);
346
- console.log(`World facts: ${result.worldFacts.length}`);
432
+ const avgRating =
433
+ ratings.length > 0 ? ratings.reduce((s, r) => s + r.rating, 0) / ratings.length : 0;
434
+ console.log(`\nAverage Rating: ${avgRating.toFixed(1)}/10`);
347
435
 
348
- if (result.opinions.length > 0) {
349
- console.log("\nTop recurring opinions:");
350
- for (const op of result.opinions.slice(0, 5)) {
351
- console.log(
352
- ` - [${op.occurrences}x, c=${op.avgConfidence.toFixed(2)}] ${op.text.slice(0, 80)}`
353
- );
436
+ const summaries = groupNoteOccurrences(notes);
437
+ console.log(`Observations: ${summaries.length} unique`);
438
+
439
+ if (opinionChanges.length > 0) {
440
+ console.log(`\nOpinion changes:`);
441
+ for (const change of opinionChanges) {
442
+ if (change.action === "created") {
443
+ console.log(
444
+ ` + NEW (${Math.round(change.newConfidence * 100)}%) ${change.statement.slice(0, 80)}`
445
+ );
446
+ } else {
447
+ console.log(
448
+ ` ~ ${Math.round(change.oldConfidence ?? 0 * 100)}% → ${Math.round(change.newConfidence * 100)}% ${change.statement.slice(0, 80)}`
449
+ );
450
+ }
354
451
  }
452
+ } else {
453
+ console.log("\nNo opinion changes");
355
454
  }
356
455
 
357
- if (values["dry-run"]) {
358
- console.log("\n[DRY RUN] Would write reflection report");
456
+ if (dryRun) {
457
+ console.log("\n[DRY RUN] Would write reflection report + update opinions");
359
458
  } else {
360
- const filepath = writeReport(result, period);
459
+ const report = formatReport(period, notes, ratings, opinionChanges);
460
+ const filepath = writeReport(report, period);
461
+ setLastReflectDate(new Date().toISOString().slice(0, 10));
361
462
  console.log(`\nCreated reflection report: ${filepath}`);
463
+
464
+ const opinions = readOpinions();
465
+ const high = opinions.filter((o) => o.confidence >= 0.85);
466
+ if (high.length > 0) {
467
+ console.log(`\nHigh-confidence opinions (injected into context):`);
468
+ for (const o of high) {
469
+ console.log(` [${Math.round(o.confidence * 100)}%] ${o.statement.slice(0, 80)}`);
470
+ }
471
+ }
362
472
  }
@@ -1,11 +0,0 @@
1
- /**
2
- * Shared prompt fragments — single source of truth for inference instructions.
3
- */
4
-
5
- /** Principle extraction instruction for failed interactions. */
6
- export const FAILURE_PRINCIPLE_PROMPT =
7
- "Write one actionable sentence that would prevent this issue from happening again. If no clear lesson, leave principle empty. Be concise.";
8
-
9
- /** Principle extraction instruction for session learnings. */
10
- export const LEARNING_PRINCIPLE_PROMPT =
11
- "If this session taught a reusable lesson, write one actionable sentence that would prevent the same issue in the future. If no clear lesson, leave empty. Be concise.";