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.
- package/assets/templates/AGENTS.md.template +6 -0
- package/assets/templates/STEERING-RULES.md +23 -0
- package/package.json +2 -2
- package/src/hooks/LoadContext.ts +1 -4
- package/src/hooks/handlers/rating.ts +9 -49
- package/src/hooks/handlers/reflect-trigger.ts +83 -0
- package/src/hooks/handlers/relationship.ts +8 -5
- package/src/hooks/handlers/session-name.ts +8 -6
- package/src/hooks/handlers/work-learning.ts +1 -0
- package/src/hooks/handlers/work-session.ts +16 -3
- package/src/hooks/lib/claude-md.ts +12 -2
- package/src/hooks/lib/context.ts +31 -21
- package/src/hooks/lib/graduation.ts +6 -4
- package/src/hooks/lib/learning-store.ts +7 -117
- package/src/hooks/lib/log.ts +1 -3
- package/src/hooks/lib/opinions.ts +191 -0
- package/src/hooks/lib/relationship.ts +5 -4
- package/src/hooks/lib/security.ts +3 -0
- package/src/hooks/lib/stop.ts +3 -0
- package/src/hooks/lib/text-similarity.ts +125 -0
- package/src/hooks/lib/work-tracking.ts +1 -1
- package/src/targets/opencode/install.ts +6 -2
- package/src/targets/opencode/plugin.ts +20 -171
- package/src/tools/analyze.ts +49 -15
- package/src/tools/opinion.ts +250 -0
- package/src/tools/relationship-reflect.ts +215 -105
- package/src/hooks/lib/prompts.ts +0 -11
- package/src/tools/eval-principles.ts +0 -234
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
* This plugin just wires opencode's hook API to those shared functions.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { writeFileSync } from "node:fs";
|
|
9
8
|
import { resolve } from "node:path";
|
|
10
9
|
import type { Plugin, PluginInput } from "@opencode-ai/plugin";
|
|
11
10
|
|
|
@@ -20,25 +19,21 @@ type TranscriptMessage = { role: string; content: string };
|
|
|
20
19
|
|
|
21
20
|
const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
22
21
|
// Pre-load shared modules
|
|
23
|
-
const {
|
|
22
|
+
const { buildSystemReminder } =
|
|
24
23
|
await lib<typeof import("../../hooks/lib/context")>("context.ts");
|
|
25
24
|
const { checkBashCommand, checkFilePath } =
|
|
26
25
|
await lib<typeof import("../../hooks/lib/security")>("security.ts");
|
|
27
|
-
const { paths, ensureDir } =
|
|
28
|
-
await lib<typeof import("../../hooks/lib/paths")>("paths.ts");
|
|
29
|
-
const { emitRating } =
|
|
30
|
-
await lib<typeof import("../../hooks/lib/signals")>("signals.ts");
|
|
31
|
-
const { now } = await lib<typeof import("../../hooks/lib/time")>("time.ts");
|
|
32
|
-
const { monthPath, fileTimestamp } =
|
|
33
|
-
await lib<typeof import("../../hooks/lib/time")>("time.ts");
|
|
34
26
|
const { logDebug, logError } =
|
|
35
27
|
await lib<typeof import("../../hooks/lib/log")>("log.ts");
|
|
36
28
|
|
|
37
|
-
// Load shared
|
|
29
|
+
// Load shared handlers
|
|
38
30
|
const { runStopHandlers } = await lib<typeof import("../../hooks/lib/stop")>("stop.ts");
|
|
39
31
|
const { captureSessionName } = await lib<
|
|
40
32
|
typeof import("../../hooks/handlers/session-name")
|
|
41
33
|
>("../handlers/session-name.ts");
|
|
34
|
+
const { captureRating } = await lib<typeof import("../../hooks/handlers/rating")>(
|
|
35
|
+
"../handlers/rating.ts"
|
|
36
|
+
);
|
|
42
37
|
|
|
43
38
|
function partsToText(parts: Array<Record<string, unknown>>): string {
|
|
44
39
|
return parts
|
|
@@ -76,59 +71,6 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
76
71
|
.filter((m) => m.content.length > 0);
|
|
77
72
|
}
|
|
78
73
|
|
|
79
|
-
const { categorizeLearning } =
|
|
80
|
-
await lib<typeof import("../../hooks/lib/learning-category")>("learning-category.ts");
|
|
81
|
-
|
|
82
|
-
// Local helpers for rating (thin wrappers around shared signals)
|
|
83
|
-
function handleRating(
|
|
84
|
-
rating: number,
|
|
85
|
-
context: string,
|
|
86
|
-
source: string,
|
|
87
|
-
detailedContext?: string,
|
|
88
|
-
userMessage?: string
|
|
89
|
-
): void {
|
|
90
|
-
emitRating(rating, context, source);
|
|
91
|
-
|
|
92
|
-
if (rating < 5) {
|
|
93
|
-
const category = categorizeLearning(context, detailedContext ?? "");
|
|
94
|
-
const dir = ensureDir(resolve(paths.sessionLearning(), monthPath()));
|
|
95
|
-
const filename = `${fileTimestamp()}_${source}-rating-${rating}_${category}.md`;
|
|
96
|
-
writeFileSync(
|
|
97
|
-
resolve(dir, filename),
|
|
98
|
-
[
|
|
99
|
-
"---",
|
|
100
|
-
`title: "${(context.slice(0, 100) || "(low rating)").replace(/"/g, '\\"')}"`,
|
|
101
|
-
`category: "${category}"`,
|
|
102
|
-
`date: "${new Date().toISOString().slice(0, 10)}"`,
|
|
103
|
-
`rating: ${rating}`,
|
|
104
|
-
`source: "${source}"`,
|
|
105
|
-
"---",
|
|
106
|
-
"",
|
|
107
|
-
"## Context",
|
|
108
|
-
context || "*(unavailable)*",
|
|
109
|
-
"",
|
|
110
|
-
...(detailedContext ? ["## Analysis", detailedContext, ""] : []),
|
|
111
|
-
].join("\n")
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
if (rating <= 3) {
|
|
116
|
-
const userPreview = userMessage?.slice(0, 400);
|
|
117
|
-
writeFileSync(
|
|
118
|
-
resolve(paths.state(), "pending-failure.json"),
|
|
119
|
-
JSON.stringify(
|
|
120
|
-
{ rating, context, source, detailedContext, userPreview, ts: now() },
|
|
121
|
-
null,
|
|
122
|
-
2
|
|
123
|
-
),
|
|
124
|
-
"utf-8"
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const PRAISE_PATTERNS =
|
|
130
|
-
/^(great\s*job|nice|perfect|awesome|excellent|thanks|thank\s*you|well\s*done|good\s*job|love\s*it|amazing|brilliant|fantastic|wonderful|superb|nailed\s*it)[.!?]?$/i;
|
|
131
|
-
|
|
132
74
|
return {
|
|
133
75
|
// --- Per-message: Inject dynamic system reminder ---
|
|
134
76
|
"experimental.chat.system.transform": async (_input, output) => {
|
|
@@ -144,7 +86,6 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
144
86
|
const { regenerateIfNeeded } =
|
|
145
87
|
await lib<typeof import("../../hooks/lib/claude-md")>("claude-md.ts");
|
|
146
88
|
regenerateIfNeeded();
|
|
147
|
-
console.log(buildGreeting().join("\n"));
|
|
148
89
|
}
|
|
149
90
|
|
|
150
91
|
if (event.type === "session.idle" || event.type === "session.diff") {
|
|
@@ -161,13 +102,15 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
161
102
|
logDebug("opencode:event", `Got ${messages.length} transcript messages`);
|
|
162
103
|
if (messages.length < 2) return;
|
|
163
104
|
|
|
164
|
-
//
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
105
|
+
// Extract last assistant message for response caching (parity with Claude Code)
|
|
106
|
+
const lastAssistant = messages
|
|
107
|
+
.filter((m: TranscriptMessage) => m.role === "assistant")
|
|
108
|
+
.pop();
|
|
169
109
|
|
|
170
|
-
await runStopHandlers(JSON.stringify(messages), {
|
|
110
|
+
await runStopHandlers(JSON.stringify(messages), {
|
|
111
|
+
sessionId: sessionID,
|
|
112
|
+
lastAssistantMessage: lastAssistant?.content,
|
|
113
|
+
});
|
|
171
114
|
logDebug("opencode:event", "Stop handlers complete");
|
|
172
115
|
} catch (err) {
|
|
173
116
|
logError("opencode:session.stop", err);
|
|
@@ -175,98 +118,19 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
175
118
|
}
|
|
176
119
|
},
|
|
177
120
|
|
|
178
|
-
// --- Capture ratings from user messages ---
|
|
179
|
-
"chat.message": async (
|
|
121
|
+
// --- Capture ratings + session naming from user messages (shared handlers) ---
|
|
122
|
+
"chat.message": async (input, output) => {
|
|
180
123
|
const text =
|
|
181
124
|
output.parts
|
|
182
125
|
?.filter((p) => p.type === "text")
|
|
183
126
|
.map((p) => p.text || "")
|
|
184
127
|
.join(" ") ?? "";
|
|
185
128
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const rating = parseInt(match[1], 10);
|
|
192
|
-
if (rating >= 1 && rating <= 10) {
|
|
193
|
-
handleRating(rating, text.slice(0, 200), "explicit", undefined, text);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// Implicit sentiment: auto-enabled when ANTHROPIC_API_KEY is set
|
|
199
|
-
if (process.env.ANTHROPIC_API_KEY) {
|
|
200
|
-
const trimmed = text.trim();
|
|
201
|
-
if (PRAISE_PATTERNS.test(trimmed)) {
|
|
202
|
-
handleRating(8, trimmed, "implicit", undefined, trimmed);
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Full implicit via API — only for medium-length messages
|
|
207
|
-
if (
|
|
208
|
-
trimmed.length >= 5 &&
|
|
209
|
-
trimmed.length <= 500 &&
|
|
210
|
-
!/^[/$`{]/.test(trimmed) &&
|
|
211
|
-
!trimmed.includes("\n\n")
|
|
212
|
-
) {
|
|
213
|
-
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
214
|
-
if (apiKey) {
|
|
215
|
-
try {
|
|
216
|
-
const response = await fetch("https://api.anthropic.com/v1/messages", {
|
|
217
|
-
method: "POST",
|
|
218
|
-
headers: {
|
|
219
|
-
"x-api-key": apiKey,
|
|
220
|
-
"anthropic-version": "2023-06-01",
|
|
221
|
-
"content-type": "application/json",
|
|
222
|
-
},
|
|
223
|
-
body: JSON.stringify({
|
|
224
|
-
model: (await lib<{ HAIKU_MODEL: string }>("models")).HAIKU_MODEL,
|
|
225
|
-
max_tokens: 100,
|
|
226
|
-
messages: [
|
|
227
|
-
{
|
|
228
|
-
role: "user",
|
|
229
|
-
content: `Rate the sentiment of this user message toward an AI assistant on a 1-10 scale (1=very negative, 5=neutral, 10=very positive). If the message has no clear sentiment toward the assistant, respond with just "neutral". Otherwise respond with just a JSON object: {"rating": N, "sentiment": "one-word"}\n\nMessage: "${trimmed.slice(0, 300)}"`,
|
|
230
|
-
},
|
|
231
|
-
],
|
|
232
|
-
}),
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
if (response.ok) {
|
|
236
|
-
const data = (await response.json()) as {
|
|
237
|
-
content?: Array<{ text?: string }>;
|
|
238
|
-
};
|
|
239
|
-
const rText = data?.content?.[0]?.text?.trim();
|
|
240
|
-
if (rText && rText !== "neutral") {
|
|
241
|
-
try {
|
|
242
|
-
const parsed = JSON.parse(rText) as {
|
|
243
|
-
rating?: number;
|
|
244
|
-
sentiment?: string;
|
|
245
|
-
};
|
|
246
|
-
if (
|
|
247
|
-
typeof parsed.rating === "number" &&
|
|
248
|
-
parsed.rating >= 1 &&
|
|
249
|
-
parsed.rating <= 10 &&
|
|
250
|
-
parsed.rating !== 5
|
|
251
|
-
) {
|
|
252
|
-
handleRating(
|
|
253
|
-
parsed.rating,
|
|
254
|
-
`${parsed.sentiment || "inferred"}: ${trimmed.slice(0, 150)}`,
|
|
255
|
-
"implicit",
|
|
256
|
-
undefined,
|
|
257
|
-
trimmed
|
|
258
|
-
);
|
|
259
|
-
}
|
|
260
|
-
} catch {
|
|
261
|
-
// Ignore parse errors
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
} catch {
|
|
266
|
-
// Ignore API errors
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
}
|
|
129
|
+
if (text.trim()) {
|
|
130
|
+
await Promise.allSettled([
|
|
131
|
+
captureRating(text, input.sessionID),
|
|
132
|
+
captureSessionName(text, input.sessionID),
|
|
133
|
+
]);
|
|
270
134
|
}
|
|
271
135
|
},
|
|
272
136
|
|
|
@@ -298,21 +162,6 @@ const PALPlugin: Plugin = async ({ directory, client }: PluginInput) => {
|
|
|
298
162
|
}
|
|
299
163
|
},
|
|
300
164
|
|
|
301
|
-
// --- Capture work state after tool use ---
|
|
302
|
-
"tool.execute.after": async (
|
|
303
|
-
input: { tool: string; sessionID: string; callID: string; args: unknown },
|
|
304
|
-
_output: { title: string; output: string; metadata: unknown }
|
|
305
|
-
) => {
|
|
306
|
-
try {
|
|
307
|
-
writeFileSync(
|
|
308
|
-
resolve(ensureDir(paths.state()), "current-work.json"),
|
|
309
|
-
JSON.stringify({ ts: now(), tool: input.tool, cwd: directory }, null, 2)
|
|
310
|
-
);
|
|
311
|
-
} catch {
|
|
312
|
-
// Ignore write errors
|
|
313
|
-
}
|
|
314
|
-
},
|
|
315
|
-
|
|
316
165
|
// --- Inject PAL_DIR into shell environment ---
|
|
317
166
|
"shell.env": async (
|
|
318
167
|
_input: { cwd: string; sessionID?: string; callID?: string },
|
package/src/tools/analyze.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
61
|
-
console.log(
|
|
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(
|
|
87
|
+
console.log(` ${c.dim("─────────────────────────────────────────────────")}\n`);
|
|
71
88
|
|
|
72
89
|
for (const candidate of result.candidates) {
|
|
73
|
-
console.log(
|
|
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"}
|
|
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(
|
|
85
|
-
|
|
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"}
|
|
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("
|
|
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(
|
|
117
|
-
console.log(
|
|
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
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* OpinionTracker — manage confidence-tracked opinions about the user.
|
|
4
|
+
*
|
|
5
|
+
* Called by the AI during conversation when it detects confirmations,
|
|
6
|
+
* contradictions, or new behavioral patterns.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* bun run tool:opinion -- list List all opinions
|
|
10
|
+
* bun run tool:opinion -- show "statement" Show opinion details
|
|
11
|
+
* bun run tool:opinion -- add "statement" [--category workflow] Add new opinion
|
|
12
|
+
* bun run tool:opinion -- evidence "statement" --supporting "why" Add supporting evidence
|
|
13
|
+
* bun run tool:opinion -- evidence "statement" --counter "why" Add counter evidence
|
|
14
|
+
* bun run tool:opinion -- evidence "statement" --confirmation "why" Explicit user confirmation
|
|
15
|
+
* bun run tool:opinion -- evidence "statement" --contradiction "why" Explicit user contradiction
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
addEvidence,
|
|
20
|
+
createOpinion,
|
|
21
|
+
type EvidenceType,
|
|
22
|
+
findSimilarOpinion,
|
|
23
|
+
type OpinionCategory,
|
|
24
|
+
readOpinions,
|
|
25
|
+
saveOpinion,
|
|
26
|
+
} from "../hooks/lib/opinions";
|
|
27
|
+
|
|
28
|
+
const args = process.argv.slice(2);
|
|
29
|
+
const command = args[0];
|
|
30
|
+
|
|
31
|
+
const NOTIFICATION_THRESHOLD = 0.15;
|
|
32
|
+
|
|
33
|
+
function flag(name: string): string | undefined {
|
|
34
|
+
const idx = args.indexOf(`--${name}`);
|
|
35
|
+
return idx !== -1 ? args[idx + 1] : undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const c = {
|
|
39
|
+
bold: (s: string) => `\x1b[1m${s}\x1b[0m`,
|
|
40
|
+
dim: (s: string) => `\x1b[2m${s}\x1b[0m`,
|
|
41
|
+
cyan: (s: string) => `\x1b[36m${s}\x1b[0m`,
|
|
42
|
+
yellow: (s: string) => `\x1b[33m${s}\x1b[0m`,
|
|
43
|
+
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
44
|
+
red: (s: string) => `\x1b[31m${s}\x1b[0m`,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function bar(confidence: number): string {
|
|
48
|
+
const filled = Math.round(confidence * 10);
|
|
49
|
+
return "\u2588".repeat(filled) + "\u2591".repeat(10 - filled);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
switch (command) {
|
|
53
|
+
case "list": {
|
|
54
|
+
const opinions = readOpinions();
|
|
55
|
+
if (opinions.length === 0) {
|
|
56
|
+
console.log("\n No opinions tracked yet.\n");
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const categories = new Map<string, typeof opinions>();
|
|
61
|
+
for (const op of opinions) {
|
|
62
|
+
const list = categories.get(op.category) ?? [];
|
|
63
|
+
list.push(op);
|
|
64
|
+
categories.set(op.category, list);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(`\n ${c.bold("Tracked Opinions")} (${opinions.length} total)\n`);
|
|
68
|
+
|
|
69
|
+
for (const [category, ops] of categories) {
|
|
70
|
+
console.log(` ${c.cyan(category)}`);
|
|
71
|
+
for (const op of ops.sort((a, b) => b.confidence - a.confidence)) {
|
|
72
|
+
const pct = `${Math.round(op.confidence * 100)}%`;
|
|
73
|
+
const color =
|
|
74
|
+
op.confidence >= 0.85 ? c.green : op.confidence <= 0.3 ? c.red : c.yellow;
|
|
75
|
+
console.log(` [${bar(op.confidence)}] ${color(pct)} ${op.statement}`);
|
|
76
|
+
}
|
|
77
|
+
console.log("");
|
|
78
|
+
}
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case "show": {
|
|
83
|
+
const statement = args[1];
|
|
84
|
+
if (!statement) {
|
|
85
|
+
console.error('Usage: bun run tool:opinion -- show "statement"');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const opinions = readOpinions();
|
|
90
|
+
const match = findSimilarOpinion(statement, opinions);
|
|
91
|
+
if (!match) {
|
|
92
|
+
console.error(` No matching opinion found for: "${statement}"`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pct = Math.round(match.confidence * 100);
|
|
97
|
+
console.log(`\n ${c.bold("Opinion Details")}\n`);
|
|
98
|
+
console.log(` ${c.bold("Statement:")} ${match.statement}`);
|
|
99
|
+
console.log(` ${c.bold("Confidence:")} [${bar(match.confidence)}] ${pct}%`);
|
|
100
|
+
console.log(` ${c.bold("Category:")} ${match.category}`);
|
|
101
|
+
console.log(` ${c.bold("Created:")} ${match.created}`);
|
|
102
|
+
console.log(` ${c.bold("Updated:")} ${match.updated}`);
|
|
103
|
+
console.log(`\n ${c.bold("Evidence")} (${match.evidence.length} items)\n`);
|
|
104
|
+
|
|
105
|
+
const supporting = match.evidence.filter(
|
|
106
|
+
(e) => e.type === "supporting" || e.type === "confirmation"
|
|
107
|
+
);
|
|
108
|
+
const counter = match.evidence.filter(
|
|
109
|
+
(e) => e.type === "counter" || e.type === "contradiction"
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
if (supporting.length > 0) {
|
|
113
|
+
console.log(` ${c.green("Supporting:")}`);
|
|
114
|
+
for (const e of supporting) {
|
|
115
|
+
console.log(` ${c.dim(e.date)} [${e.type}] ${e.source}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (counter.length > 0) {
|
|
119
|
+
console.log(` ${c.red("Counter:")}`);
|
|
120
|
+
for (const e of counter) {
|
|
121
|
+
console.log(` ${c.dim(e.date)} [${e.type}] ${e.source}`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
console.log("");
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
case "add": {
|
|
129
|
+
const statement = args[1];
|
|
130
|
+
if (!statement) {
|
|
131
|
+
console.error(
|
|
132
|
+
'Usage: bun run tool:opinion -- add "statement" [--category workflow]'
|
|
133
|
+
);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const category = (flag("category") || "general") as OpinionCategory;
|
|
138
|
+
const opinions = readOpinions();
|
|
139
|
+
const existing = findSimilarOpinion(statement, opinions);
|
|
140
|
+
|
|
141
|
+
if (existing) {
|
|
142
|
+
console.log(
|
|
143
|
+
` Similar opinion already exists: "${existing.statement}" (${Math.round(existing.confidence * 100)}%)`
|
|
144
|
+
);
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const opinion = createOpinion(statement, "manual add");
|
|
149
|
+
opinion.category = category;
|
|
150
|
+
saveOpinion(opinion);
|
|
151
|
+
console.log(` Added: "${statement}" [${category}] at 50%`);
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
case "evidence": {
|
|
156
|
+
const statement = args[1];
|
|
157
|
+
if (!statement) {
|
|
158
|
+
console.error(
|
|
159
|
+
'Usage: bun run tool:opinion -- evidence "statement" --supporting "description"'
|
|
160
|
+
);
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const opinions = readOpinions();
|
|
165
|
+
const match = findSimilarOpinion(statement, opinions);
|
|
166
|
+
if (!match) {
|
|
167
|
+
console.error(` No matching opinion found for: "${statement}"`);
|
|
168
|
+
process.exit(1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let evidenceType: EvidenceType | undefined;
|
|
172
|
+
let description: string | undefined;
|
|
173
|
+
|
|
174
|
+
for (const t of ["supporting", "counter", "confirmation", "contradiction"] as const) {
|
|
175
|
+
const val = flag(t);
|
|
176
|
+
if (val) {
|
|
177
|
+
evidenceType = t;
|
|
178
|
+
description = val;
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (!evidenceType || !description) {
|
|
184
|
+
console.error(
|
|
185
|
+
" Provide one of: --supporting, --counter, --confirmation, --contradiction"
|
|
186
|
+
);
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const oldConfidence = match.confidence;
|
|
191
|
+
const updated = addEvidence(match, evidenceType, description);
|
|
192
|
+
saveOpinion(updated);
|
|
193
|
+
|
|
194
|
+
const shift = updated.confidence - oldConfidence;
|
|
195
|
+
const arrow = shift > 0 ? c.green("\u2191") : c.red("\u2193");
|
|
196
|
+
console.log(
|
|
197
|
+
` ${arrow} ${Math.round(oldConfidence * 100)}% \u2192 ${Math.round(updated.confidence * 100)}% "${match.statement}"`
|
|
198
|
+
);
|
|
199
|
+
console.log(` [${evidenceType}] ${description}`);
|
|
200
|
+
|
|
201
|
+
if (Math.abs(shift) >= NOTIFICATION_THRESHOLD) {
|
|
202
|
+
console.log(
|
|
203
|
+
`\n ${c.bold(c.yellow("Major shift detected!"))} (${shift > 0 ? "+" : ""}${Math.round(shift * 100)}%)`
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
case "--help":
|
|
210
|
+
case "-h":
|
|
211
|
+
case "help":
|
|
212
|
+
case undefined: {
|
|
213
|
+
console.log(`
|
|
214
|
+
OpinionTracker — manage confidence-tracked opinions about the user
|
|
215
|
+
|
|
216
|
+
The "statement" argument is fuzzy-matched (Dice similarity) against all
|
|
217
|
+
stored opinions. Use a few keywords from the opinion, not the exact text.
|
|
218
|
+
|
|
219
|
+
Commands:
|
|
220
|
+
list List all opinions with confidence bars
|
|
221
|
+
show "keywords" Show opinion details + full evidence history
|
|
222
|
+
add "statement" [--category X] Create new opinion (starts at 50%)
|
|
223
|
+
evidence "keywords" --supporting "why" Supporting evidence (+2%)
|
|
224
|
+
evidence "keywords" --counter "why" Counter evidence (-5%)
|
|
225
|
+
evidence "keywords" --confirmation "why" User explicitly confirmed (+10%)
|
|
226
|
+
evidence "keywords" --contradiction "why" User explicitly contradicted (-20%)
|
|
227
|
+
|
|
228
|
+
Categories: communication, technical, workflow, general
|
|
229
|
+
|
|
230
|
+
Examples:
|
|
231
|
+
bun run tool:opinion -- list
|
|
232
|
+
bun run tool:opinion -- evidence "concise direct responses" --confirmation "User said: keep it short"
|
|
233
|
+
bun run tool:opinion -- evidence "concise direct responses" --contradiction "User asked for detailed explanation"
|
|
234
|
+
bun run tool:opinion -- add "User prefers iterative development" --category workflow
|
|
235
|
+
bun run tool:opinion -- show "iterative development"
|
|
236
|
+
|
|
237
|
+
Confidence lifecycle:
|
|
238
|
+
New opinions start at 50%. Supporting notes from reflect add +2% each.
|
|
239
|
+
Explicit confirmations jump +10%, contradictions drop -20%.
|
|
240
|
+
At >=85%, opinions are auto-injected into every session context.
|
|
241
|
+
|
|
242
|
+
Usage: bun run tool:opinion -- <command> [args]
|
|
243
|
+
`);
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
default:
|
|
248
|
+
console.error(` Unknown command: ${command}. Run with --help for usage.`);
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|