portable-agent-layer 0.9.0 → 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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Opinion store — persistent confidence-tracked opinions about the user.
3
+ *
4
+ * Opinions are promoted from recurring relationship notes (O/B types) by the
5
+ * reflect tool. Confidence evolves with evidence over time.
6
+ * High-confidence opinions (≥85%) are injected into every session context.
7
+ *
8
+ * Storage: memory/relationship/opinions.json
9
+ */
10
+
11
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { paths } from "./paths";
14
+ import { similarity } from "./text-similarity";
15
+
16
+ // ── Types ──
17
+
18
+ export type EvidenceType = "supporting" | "counter" | "confirmation" | "contradiction";
19
+ export type OpinionCategory = "communication" | "technical" | "workflow" | "general";
20
+
21
+ export interface Evidence {
22
+ date: string;
23
+ type: EvidenceType;
24
+ source: string;
25
+ }
26
+
27
+ export interface Opinion {
28
+ id: string;
29
+ statement: string;
30
+ confidence: number;
31
+ category: OpinionCategory;
32
+ evidence: Evidence[];
33
+ created: string;
34
+ updated: string;
35
+ }
36
+
37
+ // ── Confidence Deltas (matching original PAI) ──
38
+
39
+ const CONFIDENCE_DELTAS: Record<EvidenceType, number> = {
40
+ supporting: 0.02,
41
+ counter: -0.05,
42
+ confirmation: 0.1,
43
+ contradiction: -0.2,
44
+ };
45
+
46
+ const MIN_CONFIDENCE = 0.01;
47
+ const MAX_CONFIDENCE = 0.99;
48
+ const HIGH_CONFIDENCE_THRESHOLD = 0.85;
49
+
50
+ // ── Category Classification ──
51
+
52
+ const CATEGORY_MAP: [RegExp, OpinionCategory][] = [
53
+ [/tone|format|response|verbose|brief|concise|explain|direct|style/i, "communication"],
54
+ [/code|test|build|deploy|function|type|lint|debug|refactor/i, "technical"],
55
+ [/commit|git|release|workflow|process|approach|iterative|step/i, "workflow"],
56
+ ];
57
+
58
+ function classifyCategory(text: string): OpinionCategory {
59
+ for (const [pattern, category] of CATEGORY_MAP) {
60
+ if (pattern.test(text)) return category;
61
+ }
62
+ return "general";
63
+ }
64
+
65
+ // ── File Path ──
66
+
67
+ function opinionsPath(): string {
68
+ return resolve(paths.relationship(), "opinions.json");
69
+ }
70
+
71
+ // ── Store Format ──
72
+
73
+ interface OpinionStore {
74
+ lastReflect: string;
75
+ opinions: Opinion[];
76
+ }
77
+
78
+ // ── CRUD ──
79
+
80
+ function readStore(): OpinionStore {
81
+ const fp = opinionsPath();
82
+ if (!existsSync(fp)) return { lastReflect: "", opinions: [] };
83
+ try {
84
+ const raw = JSON.parse(readFileSync(fp, "utf-8"));
85
+ // Migrate from plain array format
86
+ if (Array.isArray(raw)) return { lastReflect: "", opinions: raw };
87
+ return raw as OpinionStore;
88
+ } catch {
89
+ return { lastReflect: "", opinions: [] };
90
+ }
91
+ }
92
+
93
+ function writeStore(store: OpinionStore): void {
94
+ writeFileSync(opinionsPath(), JSON.stringify(store, null, 2), "utf-8");
95
+ }
96
+
97
+ export function readOpinions(): Opinion[] {
98
+ return readStore().opinions;
99
+ }
100
+
101
+ export function getLastReflectDate(): string {
102
+ return readStore().lastReflect;
103
+ }
104
+
105
+ export function setLastReflectDate(date: string): void {
106
+ const store = readStore();
107
+ store.lastReflect = date;
108
+ writeStore(store);
109
+ }
110
+
111
+ function slugify(text: string): string {
112
+ return text
113
+ .toLowerCase()
114
+ .replace(/[^a-z0-9\s]/g, "")
115
+ .trim()
116
+ .split(/\s+/)
117
+ .slice(0, 6)
118
+ .join("-");
119
+ }
120
+
121
+ /** Find an existing opinion similar to the given text. Returns the opinion or null. */
122
+ export function findSimilarOpinion(
123
+ text: string,
124
+ opinions: Opinion[],
125
+ threshold = 0.3
126
+ ): Opinion | null {
127
+ for (const op of opinions) {
128
+ if (similarity(text, op.statement) >= threshold) return op;
129
+ }
130
+ return null;
131
+ }
132
+
133
+ /** Create a new opinion from a recurring note. Starts at confidence 0.50. */
134
+ export function createOpinion(statement: string, source: string): Opinion {
135
+ const now = new Date().toISOString().slice(0, 10);
136
+ return {
137
+ id: slugify(statement),
138
+ statement,
139
+ confidence: 0.5,
140
+ category: classifyCategory(statement),
141
+ evidence: [{ date: now, type: "supporting", source }],
142
+ created: now,
143
+ updated: now,
144
+ };
145
+ }
146
+
147
+ /** Add evidence to an opinion and adjust its confidence. */
148
+ export function addEvidence(
149
+ opinion: Opinion,
150
+ type: EvidenceType,
151
+ source: string
152
+ ): Opinion {
153
+ const now = new Date().toISOString().slice(0, 10);
154
+ const delta = CONFIDENCE_DELTAS[type];
155
+ const newConfidence = Math.min(
156
+ MAX_CONFIDENCE,
157
+ Math.max(MIN_CONFIDENCE, opinion.confidence + delta)
158
+ );
159
+
160
+ return {
161
+ ...opinion,
162
+ confidence: Math.round(newConfidence * 100) / 100,
163
+ evidence: [...opinion.evidence, { date: now, type, source }],
164
+ updated: now,
165
+ };
166
+ }
167
+
168
+ /** Upsert an opinion into the store. */
169
+ export function saveOpinion(opinion: Opinion): void {
170
+ const store = readStore();
171
+ const idx = store.opinions.findIndex((o) => o.id === opinion.id);
172
+ if (idx >= 0) {
173
+ store.opinions[idx] = opinion;
174
+ } else {
175
+ store.opinions.push(opinion);
176
+ }
177
+ writeStore(store);
178
+ }
179
+
180
+ // ── Context Loading ──
181
+
182
+ /** Load high-confidence opinions formatted for system-reminder injection. */
183
+ export function loadOpinionContext(threshold = HIGH_CONFIDENCE_THRESHOLD): string {
184
+ const opinions = readOpinions().filter((o) => o.confidence >= threshold);
185
+ if (opinions.length === 0) return "";
186
+
187
+ const lines = opinions.map(
188
+ (o) => `- [${o.category}] ${o.statement} (${Math.round(o.confidence * 100)}%)`
189
+ );
190
+ return ["## Tracked Opinions", ...lines].join("\n");
191
+ }
@@ -5,6 +5,7 @@
5
5
  * Notes live at memory/relationship/YYYY-MM/YYYY-MM-DD.md
6
6
  * W = world (facts about user's situation)
7
7
  * O = opinion (preference with confidence)
8
+ * B = belief (behavioral pattern with confidence)
8
9
  *
9
10
  * Extraction is handled by the relationship handler via Haiku inference.
10
11
  * This lib provides storage and reading utilities only.
@@ -14,7 +15,7 @@ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
14
15
  import { resolve } from "node:path";
15
16
  import { ensureDir, paths } from "./paths";
16
17
 
17
- export type NoteType = "W" | "O";
18
+ export type NoteType = "W" | "O" | "B";
18
19
 
19
20
  export interface RelationshipNote {
20
21
  type: NoteType;
@@ -79,8 +80,8 @@ export function appendNotes(notes: RelationshipNote[], sessionId?: string): void
79
80
  if (sessionId) lines.push(`<!-- session:${sessionId} -->`);
80
81
 
81
82
  for (const note of fresh) {
82
- if (note.type === "O" && note.confidence !== undefined) {
83
- lines.push(`- O(c=${note.confidence}): ${note.text}`);
83
+ if ((note.type === "O" || note.type === "B") && note.confidence !== undefined) {
84
+ lines.push(`- ${note.type}(c=${note.confidence}): ${note.text}`);
84
85
  } else {
85
86
  lines.push(`- ${note.type}: ${note.text}`);
86
87
  }
@@ -103,8 +104,8 @@ export function loadRecentNotes(days: number = 2): string {
103
104
  const sections: string[] = [];
104
105
 
105
106
  for (const monthDir of readdirSync(relDir).sort().reverse()) {
107
+ if (!/^\d{4}-\d{2}$/.test(monthDir)) continue;
106
108
  const monthPath = resolve(relDir, monthDir);
107
- if (!existsSync(monthPath)) continue;
108
109
 
109
110
  let files: string[];
110
111
  try {
@@ -32,6 +32,7 @@ export const HOOK_MANAGED_FILES = [
32
32
  "graduated.json",
33
33
  "update-available.json",
34
34
  "debug.log.prev",
35
+ "opinions.json",
35
36
  ];
36
37
 
37
38
  /** Hook-managed directories — AI must not write to or delete from these */
@@ -7,6 +7,7 @@ import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { autoBackup } from "../handlers/backup";
9
9
  import { captureFailure } from "../handlers/failure";
10
+ import { checkReflectTrigger } from "../handlers/reflect-trigger";
10
11
  import { captureRelationship } from "../handlers/relationship";
11
12
  import { resetTab } from "../handlers/tab";
12
13
  import { updateCounts } from "../handlers/update-counts";
@@ -43,6 +44,7 @@ export async function runStopHandlers(
43
44
  checkPendingFailure(transcript),
44
45
  updateCounts(),
45
46
  autoBackup(),
47
+ checkReflectTrigger(),
46
48
  ]);
47
49
 
48
50
  const handlerNames = [
@@ -53,6 +55,7 @@ export async function runStopHandlers(
53
55
  "pending-failure",
54
56
  "update-counts",
55
57
  "backup",
58
+ "reflect-trigger",
56
59
  ];
57
60
  for (let i = 0; i < results.length; i++) {
58
61
  const r = results[i];
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Text similarity using Dice coefficient on keyword sets.
3
+ *
4
+ * Dice is more generous than Jaccard for short, variable-length texts
5
+ * (like chatbot messages and failure contexts) because it divides by
6
+ * average set size instead of union:
7
+ *
8
+ * Dice = 2 * |intersection| / (|A| + |B|)
9
+ */
10
+
11
+ const STOP_WORDS = new Set([
12
+ "the",
13
+ "a",
14
+ "an",
15
+ "is",
16
+ "was",
17
+ "are",
18
+ "were",
19
+ "be",
20
+ "been",
21
+ "being",
22
+ "have",
23
+ "has",
24
+ "had",
25
+ "do",
26
+ "does",
27
+ "did",
28
+ "will",
29
+ "would",
30
+ "could",
31
+ "should",
32
+ "may",
33
+ "might",
34
+ "can",
35
+ "shall",
36
+ "to",
37
+ "of",
38
+ "in",
39
+ "for",
40
+ "on",
41
+ "with",
42
+ "at",
43
+ "by",
44
+ "from",
45
+ "as",
46
+ "into",
47
+ "through",
48
+ "during",
49
+ "before",
50
+ "after",
51
+ "and",
52
+ "but",
53
+ "or",
54
+ "nor",
55
+ "not",
56
+ "no",
57
+ "so",
58
+ "if",
59
+ "then",
60
+ "than",
61
+ "that",
62
+ "this",
63
+ "it",
64
+ "its",
65
+ "i",
66
+ "you",
67
+ "he",
68
+ "she",
69
+ "we",
70
+ "they",
71
+ "my",
72
+ "your",
73
+ "his",
74
+ "her",
75
+ "our",
76
+ "their",
77
+ "what",
78
+ "which",
79
+ "who",
80
+ "when",
81
+ "where",
82
+ "how",
83
+ "all",
84
+ "each",
85
+ "both",
86
+ "few",
87
+ "more",
88
+ "most",
89
+ "other",
90
+ "some",
91
+ "such",
92
+ "up",
93
+ "out",
94
+ "about",
95
+ "just",
96
+ "also",
97
+ "very",
98
+ "too",
99
+ "only",
100
+ "own",
101
+ ]);
102
+
103
+ export function extractKeywords(text: string): Set<string> {
104
+ return new Set(
105
+ text
106
+ .toLowerCase()
107
+ .replace(/[^a-z0-9\s-]/g, " ")
108
+ .split(/\s+/)
109
+ .filter((w) => w.length > 2 && !STOP_WORDS.has(w))
110
+ );
111
+ }
112
+
113
+ /** Dice coefficient on keyword sets. Returns 0-1. */
114
+ export function similarity(a: string, b: string): number {
115
+ const ka = extractKeywords(a);
116
+ const kb = extractKeywords(b);
117
+ if (ka.size === 0 || kb.size === 0) return 0;
118
+
119
+ let intersection = 0;
120
+ for (const w of ka) {
121
+ if (kb.has(w)) intersection++;
122
+ }
123
+
124
+ return (2 * intersection) / (ka.size + kb.size);
125
+ }
@@ -11,6 +11,18 @@
11
11
  import { parseArgs } from "node:util";
12
12
  import { analyze } from "../hooks/lib/graduation";
13
13
 
14
+ // ── ANSI Colors ──
15
+
16
+ const c = {
17
+ bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
18
+ dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
19
+ cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
20
+ yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
21
+ green: (s: string) => `\x1b[32m${s}\x1b[0m`,
22
+ red: (s: string) => `\x1b[31m${s}\x1b[0m`,
23
+ magenta: (s: string) => `\x1b[35m${s}\x1b[0m`,
24
+ };
25
+
14
26
  const { values } = parseArgs({
15
27
  args: Bun.argv.slice(2),
16
28
  options: {
@@ -24,7 +36,7 @@ if (values.help) {
24
36
  PAL Learning Analysis — unified graduation + ratings report
25
37
 
26
38
  Reads all captured failures (rating ≤3) and session learnings,
27
- groups recurring patterns via Jaccard similarity on context text,
39
+ groups recurring patterns via Dice similarity on context text,
28
40
  and summarizes rating trends.
29
41
 
30
42
  Sections:
@@ -57,47 +69,69 @@ if (!hasPatterns && !hasRatings) {
57
69
 
58
70
  if (result.ratings) {
59
71
  const r = result.ratings;
60
- console.log(`\n Ratings: ${r.average.toFixed(1)}/10 avg (${r.total} total)`);
61
- console.log(` Low (≤4): ${r.low.count} | High (≥7): ${r.high.count}`);
72
+ const avgColor = r.average >= 7 ? c.green : r.average <= 4 ? c.red : c.yellow;
73
+ console.log(
74
+ `\n ${c.bold("Ratings:")} ${avgColor(`${r.average.toFixed(1)}/10`)} avg (${r.total} total)`
75
+ );
76
+ console.log(
77
+ ` ${c.red(`Low (≤4): ${r.low.count}`)} | ${c.green(`High (≥7): ${r.high.count}`)}`
78
+ );
62
79
  }
63
80
 
64
81
  // ── Graduation Candidates ──
65
82
 
66
83
  if (result.candidates.length > 0) {
67
84
  console.log(
68
- `\n Graduation Report — ${result.candidates.length} pattern(s) detected\n`
85
+ `\n ${c.bold(c.green(`Graduation Report — ${result.candidates.length} pattern(s) detected`))}\n`
69
86
  );
70
- console.log(" ─────────────────────────────────────────────────\n");
87
+ console.log(` ${c.dim("─────────────────────────────────────────────────")}\n`);
71
88
 
72
89
  for (const candidate of result.candidates) {
73
- console.log(` [${candidate.domain}] ${candidate.entries.length}x occurrences`);
90
+ console.log(
91
+ ` ${c.cyan(`[${candidate.domain}]`)} ${c.bold(`${candidate.entries.length}x`)} occurrences`
92
+ );
74
93
  console.log("");
75
94
 
76
95
  for (const entry of candidate.entries) {
77
96
  const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
97
+ const tag =
98
+ sourceType === "failure" ? c.red(`[${sourceType}]`) : c.yellow(`[${sourceType}]`);
78
99
  console.log(
79
- ` ${entry.date || "unknown"} [${sourceType}] ${entry.text.slice(0, 100)}`
100
+ ` ${c.dim(entry.date || "unknown")} ${tag} ${entry.text.slice(0, 100)}`
80
101
  );
81
102
  }
82
103
 
104
+ console.log(`\n ${c.dim("Files:")}`);
105
+ for (const entry of candidate.entries) {
106
+ console.log(` ${c.dim(entry.path)}`);
107
+ }
108
+
83
109
  console.log("");
84
- console.log(" Target frame:", `memory/wisdom/frames/${candidate.domain}.md`);
85
- console.log(" ─────────────────────────────────────────────────\n");
110
+ console.log(
111
+ ` Target frame: ${c.magenta(`memory/wisdom/frames/${candidate.domain}.md`)}`
112
+ );
113
+ console.log(` ${c.dim("─────────────────────────────────────────────────")}\n`);
86
114
  }
87
115
  }
88
116
 
89
117
  // ── Emerging Patterns ──
90
118
 
91
119
  if (result.emerging.length > 0) {
92
- console.log(` Emerging (2x — one more to graduate)\n`);
120
+ console.log(` ${c.bold(c.yellow("Emerging (2x — one more to graduate)"))}\n`);
93
121
  for (const group of result.emerging) {
94
- console.log(` [${group.domain}] ${group.entries.length}x`);
122
+ console.log(` ${c.cyan(`[${group.domain}]`)} ${c.bold(`${group.entries.length}x`)}`);
95
123
  for (const entry of group.entries) {
96
124
  const sourceType = entry.source.startsWith("failure:") ? "failure" : "learning";
125
+ const tag =
126
+ sourceType === "failure" ? c.red(`[${sourceType}]`) : c.yellow(`[${sourceType}]`);
97
127
  console.log(
98
- ` ${entry.date || "unknown"} [${sourceType}] ${entry.text.slice(0, 80)}`
128
+ ` ${c.dim(entry.date || "unknown")} ${tag} ${entry.text.slice(0, 80)}`
99
129
  );
100
130
  }
131
+ console.log(" Files:");
132
+ for (const entry of group.entries) {
133
+ console.log(` ${c.dim(entry.path)}`);
134
+ }
101
135
  console.log("");
102
136
  }
103
137
  }
@@ -105,7 +139,7 @@ if (result.emerging.length > 0) {
105
139
  // ── Recommendations ──
106
140
 
107
141
  if (result.recommendations.length > 0) {
108
- console.log(" Recommendations:\n");
142
+ console.log(` ${c.bold("Recommendations:")}\n`);
109
143
  for (const rec of result.recommendations) {
110
144
  console.log(` ${rec}`);
111
145
  }
@@ -113,6 +147,6 @@ if (result.recommendations.length > 0) {
113
147
  }
114
148
 
115
149
  if (result.candidates.length > 0) {
116
- console.log(" To crystallize: add a line to the wisdom frame file.");
117
- console.log(" Format: - Your principle here [CRYSTAL: 85%]\n");
150
+ console.log(` To crystallize: add a line to the wisdom frame file.`);
151
+ console.log(` Format: ${c.green("- Your principle here [CRYSTAL: 85%]")}\n`);
118
152
  }