@ramarivera/coding-buddy 0.8.0 → 0.9.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/README.md +23 -4
- package/adapters/omp/context.ts +1 -1
- package/adapters/omp/events.ts +286 -81
- package/adapters/omp/index.ts +3 -1
- package/adapters/omp/renderers.ts +4 -54
- package/adapters/omp/storage.ts +6 -1
- package/adapters/omp/ui.ts +30 -1
- package/adapters/pi/events.ts +280 -83
- package/adapters/pi/index.ts +11 -2
- package/adapters/pi/renderers.ts +3 -47
- package/adapters/pi/storage.ts +6 -1
- package/adapters/pi/ui.ts +30 -1
- package/adapters/shared/file-storage.ts +21 -0
- package/adapters/shared/widget-layout.ts +134 -2
- package/cli/settings.ts +1 -1
- package/cli/tui.tsx +1 -1
- package/core/command-service.ts +13 -0
- package/core/engine.ts +11 -2
- package/core/identity.ts +15 -0
- package/core/model.ts +2 -0
- package/core/ports.ts +4 -0
- package/core/reactions.ts +29 -2
- package/core/render-model.ts +4 -3
- package/package.json +6 -2
- package/scripts/ci/Dockerfile.vhs +125 -0
- package/scripts/ci/check-statusline.sh +328 -14
- package/scripts/ci/check-vhs-goldens.sh +97 -0
- package/scripts/ci/fonts.conf +37 -0
- package/scripts/ci/goldens/baseline-40.png +0 -0
- package/scripts/ci/goldens/baseline-80.png +0 -0
- package/scripts/ci/goldens/cjk-80.png +0 -0
- package/scripts/ci/goldens/emoji-80.png +0 -0
- package/scripts/ci/render-statusline-pty.py +99 -0
- package/scripts/ci/render-vhs-goldens.sh +235 -0
- package/scripts/ci/vhs-run-statusline.sh +16 -0
- package/scripts/gen-emoji-widths.ts +41 -17
- package/scripts/paths.sh +3 -1
- package/server/art.ts +45 -13
- package/server/hooks/buddy-comment.ts +166 -17
- package/server/hooks/file-type-react.ts +1 -1
- package/server/hooks/mood-react.ts +1 -1
- package/server/hooks/name-react.ts +1 -1
- package/server/hooks/react.ts +1 -1
- package/server/hooks/reaction-data.ts +10 -3
- package/server/index.ts +67 -27
- package/server/path.ts +3 -0
- package/server/state.ts +63 -17
- package/skills/buddy/SKILL.md +1 -1
- package/statusline/buddy-status.sh +351 -59
- package/statusline/emoji-text.data +3 -0
- package/statusline/emoji-widths.data +2 -3
- package/statusline/substatus.sh +52 -10
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
-
import { mkdirSync, writeFileSync } from "fs";
|
|
3
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
4
4
|
import { join } from "path";
|
|
5
|
+
import { reactionPool } from "./reaction-data.ts";
|
|
5
6
|
import {
|
|
6
7
|
defaultSpawnDetached,
|
|
7
8
|
fileExists,
|
|
8
9
|
isOnCooldown,
|
|
9
10
|
nonNegativeInteger,
|
|
10
11
|
parseHookInput,
|
|
12
|
+
pickRandom,
|
|
11
13
|
readJsonFile,
|
|
12
14
|
readStdin,
|
|
13
15
|
resolveHookSessionId,
|
|
@@ -25,10 +27,29 @@ interface Events {
|
|
|
25
27
|
[key: string]: unknown;
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
interface BuddyStatus {
|
|
31
|
+
species?: unknown;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ReactionFile {
|
|
36
|
+
source?: string;
|
|
37
|
+
timestamp?: number;
|
|
38
|
+
[key: string]: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
28
41
|
const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
|
|
29
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Provenance of the reaction the hook wrote. Mirrors server/state.ts
|
|
45
|
+
* `ReactionSource`; loaded as `none` on legacy files without the field.
|
|
46
|
+
*/
|
|
47
|
+
export type ReactionSource = "comment" | "fallback" | "none";
|
|
48
|
+
|
|
30
49
|
export interface BuddyCommentResult {
|
|
31
50
|
comment?: string;
|
|
51
|
+
/** What produced the bubble. `"none"` means the hook ran but wrote nothing. */
|
|
52
|
+
source: ReactionSource;
|
|
32
53
|
updated: boolean;
|
|
33
54
|
}
|
|
34
55
|
|
|
@@ -41,37 +62,164 @@ export function extractBuddyComment(message: string): string {
|
|
|
41
62
|
return comment;
|
|
42
63
|
}
|
|
43
64
|
|
|
44
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Tolerate tool-stamped timestamps that are slightly in the future (NTP
|
|
67
|
+
* step, container/host skew, network-mounted state dir). Larger drifts
|
|
68
|
+
* are treated as clock-skewed garbage and the hook falls through to
|
|
69
|
+
* the comment / pool branch.
|
|
70
|
+
*/
|
|
71
|
+
const FUTURE_TIMESTAMP_TOLERANCE_MS = 60_000;
|
|
72
|
+
|
|
73
|
+
function pickTurnFallback(
|
|
74
|
+
species: string,
|
|
75
|
+
runtime: HookRuntime,
|
|
76
|
+
): string | undefined {
|
|
77
|
+
// The hook context has no stat-modifier inputs; pick from the canned
|
|
78
|
+
// pool directly rather than calling server/reactions.ts `getReaction`,
|
|
79
|
+
// which is reserved for the MCP server's tool-call path. This mirrors
|
|
80
|
+
// the file-type-react / mood-react / react hook convention.
|
|
81
|
+
const pool = reactionPool(species, "turn");
|
|
82
|
+
if (pool.length === 0) return undefined;
|
|
83
|
+
return pickRandom(pool, runtime);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function readSpeciesFromStatus(stateDir: string): string {
|
|
87
|
+
const status = readJsonFile<BuddyStatus>(join(stateDir, "status.json"));
|
|
88
|
+
return typeof status?.species === "string" && status.species.length > 0
|
|
89
|
+
? status.species
|
|
90
|
+
: "blob";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Atomic write — tmp + rename. */
|
|
94
|
+
function atomicWriteJson(path: string, value: unknown): void {
|
|
95
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
96
|
+
writeFileSync(tmp, JSON.stringify(value, null, 2));
|
|
97
|
+
renameSync(tmp, path);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function atomicWriteTimestamp(path: string, nowMs: number): void {
|
|
101
|
+
const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
|
|
102
|
+
writeFileSync(tmp, String(Math.floor(nowMs / 1000)));
|
|
103
|
+
renameSync(tmp, path);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function readTimestampSeconds(path: string): number {
|
|
107
|
+
try {
|
|
108
|
+
const v = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
109
|
+
return Number.isFinite(v) ? v : 0;
|
|
110
|
+
} catch {
|
|
111
|
+
return 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Did `buddy_react` fire during the current turn?
|
|
117
|
+
*
|
|
118
|
+
* The Stop hook is the only place that knows turn boundaries. After each
|
|
119
|
+
* Stop hook run, we stamp the wall-clock time into
|
|
120
|
+
* `.last_stop_hook.<sid>` (seconds). A tool-author ed reaction whose
|
|
121
|
+
* timestamp is more recent than the LAST Stop hook run is by definition
|
|
122
|
+
* from this turn; leave it alone.
|
|
123
|
+
*
|
|
124
|
+
* Wall-clock 60 s freshness windows lose tool reactions on agentic
|
|
125
|
+
* turns of 90 s–5 min, which are ordinary. This sentinel scales with
|
|
126
|
+
* turn length, not elapsed time.
|
|
127
|
+
*
|
|
128
|
+
* Clock-skew guard: a tool timestamp implausibly in the future (beyond
|
|
129
|
+
* `FUTURE_TIMESTAMP_TOLERANCE_MS`) is treated as garbage and we fall
|
|
130
|
+
* through to the comment / pool branch. A future-dated `lastStopRun`
|
|
131
|
+
* is clamped to `now` so a skewed clock on a prior run does not poison
|
|
132
|
+
* the comparison.
|
|
133
|
+
*/
|
|
134
|
+
function toolFiredThisTurn(
|
|
135
|
+
reactionPath: string,
|
|
136
|
+
stopMarkerPath: string,
|
|
137
|
+
nowMs: number,
|
|
138
|
+
): boolean {
|
|
139
|
+
const existing = readJsonFile<ReactionFile>(reactionPath);
|
|
140
|
+
if (!existing || existing.source !== "tool") return false;
|
|
141
|
+
const ts = typeof existing.timestamp === "number" ? existing.timestamp : 0;
|
|
142
|
+
if (ts <= 0) return false;
|
|
143
|
+
if (ts > nowMs + FUTURE_TIMESTAMP_TOLERANCE_MS) return false;
|
|
144
|
+
const lastStopSec = readTimestampSeconds(stopMarkerPath);
|
|
145
|
+
const effectiveLastStopMs = Math.min(lastStopSec * 1000, nowMs);
|
|
146
|
+
return ts > effectiveLastStopMs;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function handleBuddyComment(
|
|
150
|
+
rawInput: string,
|
|
151
|
+
runtime: HookRuntime = {},
|
|
152
|
+
): BuddyCommentResult {
|
|
45
153
|
const stateDir = resolveHookStateDir(runtime);
|
|
46
|
-
if (!fileExists(join(stateDir, "status.json")))
|
|
154
|
+
if (!fileExists(join(stateDir, "status.json"))) {
|
|
155
|
+
return { source: "none", updated: false };
|
|
156
|
+
}
|
|
47
157
|
|
|
48
158
|
const input = parseHookInput(rawInput);
|
|
49
|
-
if (!input) return { updated: false };
|
|
159
|
+
if (!input) return { source: "none", updated: false };
|
|
50
160
|
|
|
51
161
|
const assistantMessage = stringField(input, "last_assistant_message");
|
|
52
|
-
if (!assistantMessage) return { updated: false };
|
|
53
|
-
|
|
54
|
-
const comment = extractBuddyComment(assistantMessage);
|
|
55
|
-
if (!comment) return { updated: false };
|
|
162
|
+
if (!assistantMessage) return { source: "none", updated: false };
|
|
56
163
|
|
|
57
164
|
const now = runtime.now?.() ?? Date.now();
|
|
58
165
|
const sid = resolveHookSessionId(runtime);
|
|
166
|
+
const reactionPath = join(stateDir, `reaction.${sid}.json`);
|
|
167
|
+
const cooldownFile = join(stateDir, `.last_comment.${sid}`);
|
|
168
|
+
const stopMarkerFile = join(stateDir, `.last_stop_hook.${sid}`);
|
|
59
169
|
const config = readJsonFile<BuddyConfig>(join(stateDir, "config.json")) ?? {};
|
|
60
170
|
const cooldown = nonNegativeInteger(config.commentCooldown, 30);
|
|
61
|
-
|
|
62
|
-
|
|
171
|
+
|
|
172
|
+
// ─── Don't clobber a buddy_react tool reaction from this turn ───────────
|
|
173
|
+
if (toolFiredThisTurn(reactionPath, stopMarkerFile, now)) {
|
|
174
|
+
atomicWriteTimestamp(stopMarkerFile, now);
|
|
175
|
+
return { source: "none", updated: false };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Cooldown: rate-limit the reaction write AND bookkeeping ──────────
|
|
179
|
+
// Main ran the turn counter / XP / memory hooks only inside the
|
|
180
|
+
// "reaction written" branch. Running them unconditionally makes the
|
|
181
|
+
// XP economy and spawn counts scale with Stop event frequency, which
|
|
182
|
+
// is wrong.
|
|
183
|
+
if (isOnCooldown(cooldownFile, cooldown, now)) {
|
|
184
|
+
atomicWriteTimestamp(stopMarkerFile, now);
|
|
185
|
+
return { source: "none", updated: false };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ─── Pick a reaction: legacy comment → canned pool → nothing ────────────
|
|
189
|
+
const commentFromMessage = extractBuddyComment(assistantMessage);
|
|
190
|
+
let comment: string | undefined;
|
|
191
|
+
let source: ReactionSource;
|
|
192
|
+
|
|
193
|
+
if (commentFromMessage) {
|
|
194
|
+
comment = commentFromMessage;
|
|
195
|
+
source = "comment";
|
|
196
|
+
} else {
|
|
197
|
+
const species = readSpeciesFromStatus(stateDir);
|
|
198
|
+
const fallback = pickTurnFallback(species, runtime);
|
|
199
|
+
if (fallback) {
|
|
200
|
+
comment = fallback;
|
|
201
|
+
source = "fallback";
|
|
202
|
+
} else {
|
|
203
|
+
atomicWriteTimestamp(stopMarkerFile, now);
|
|
204
|
+
return { source: "none", updated: false };
|
|
205
|
+
}
|
|
206
|
+
}
|
|
63
207
|
|
|
64
208
|
mkdirSync(stateDir, { recursive: true });
|
|
65
|
-
writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
|
|
66
|
-
writeFileSync(
|
|
67
|
-
join(stateDir, `reaction.${sid}.json`),
|
|
68
|
-
JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
|
|
69
|
-
);
|
|
70
209
|
|
|
210
|
+
// Bookkeeping fires only when a reaction is actually written (main).
|
|
71
211
|
const eventsFile = join(stateDir, "events.json");
|
|
72
212
|
const events = readJsonFile<Events>(eventsFile) ?? {};
|
|
73
213
|
events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
|
|
74
|
-
|
|
214
|
+
atomicWriteJson(eventsFile, events);
|
|
215
|
+
|
|
216
|
+
atomicWriteTimestamp(cooldownFile, now);
|
|
217
|
+
atomicWriteJson(reactionPath, {
|
|
218
|
+
reaction: comment,
|
|
219
|
+
timestamp: now,
|
|
220
|
+
reason: "turn",
|
|
221
|
+
source,
|
|
222
|
+
});
|
|
75
223
|
|
|
76
224
|
const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
|
|
77
225
|
spawnDetached("server/award-xp.ts", ["turn"]);
|
|
@@ -79,8 +227,9 @@ export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}):
|
|
|
79
227
|
assistantMessage,
|
|
80
228
|
stringField(input, "last_user_message"),
|
|
81
229
|
]);
|
|
230
|
+
atomicWriteTimestamp(stopMarkerFile, now);
|
|
82
231
|
|
|
83
|
-
return { comment, updated: true };
|
|
232
|
+
return { comment, source, updated: true };
|
|
84
233
|
}
|
|
85
234
|
|
|
86
235
|
if (import.meta.main) {
|
|
@@ -130,7 +130,7 @@ export function handleFileTypeReact(rawInput: string, runtime: HookRuntime = {})
|
|
|
130
130
|
writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(Math.floor(now / 1000)));
|
|
131
131
|
writeFileSync(
|
|
132
132
|
join(stateDir, `reaction.${sid}.json`),
|
|
133
|
-
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType }),
|
|
133
|
+
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType, source: "fallback" }),
|
|
134
134
|
);
|
|
135
135
|
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
|
|
136
136
|
|
|
@@ -89,7 +89,7 @@ export function handleMoodReact(rawInput: string, runtime: HookRuntime = {}): Mo
|
|
|
89
89
|
writeFileSync(join(stateDir, `.last_mood.${sid}`), String(Math.floor(now / 1000)));
|
|
90
90
|
writeFileSync(
|
|
91
91
|
join(stateDir, `reaction.${sid}.json`),
|
|
92
|
-
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood }),
|
|
92
|
+
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood, source: "fallback" }),
|
|
93
93
|
);
|
|
94
94
|
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
|
|
95
95
|
incrementEvent(stateDir, `mood_${mood}`);
|
|
@@ -82,7 +82,7 @@ export function handleNameReact(rawInput: string, runtime: HookRuntime = {}): Na
|
|
|
82
82
|
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
|
|
83
83
|
writeFileSync(
|
|
84
84
|
join(stateDir, `reaction.${sid}.json`),
|
|
85
|
-
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name" }),
|
|
85
|
+
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name", source: "fallback" }),
|
|
86
86
|
);
|
|
87
87
|
|
|
88
88
|
return { reaction, updated: true };
|
package/server/hooks/react.ts
CHANGED
|
@@ -386,7 +386,7 @@ export function handleReact(rawInput: string, runtime: HookRuntime = {}): ReactR
|
|
|
386
386
|
writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(clock.nowSeconds));
|
|
387
387
|
writeFileSync(
|
|
388
388
|
join(stateDir, `reaction.${sid}.json`),
|
|
389
|
-
JSON.stringify({ reaction, timestamp: clock.nowSeconds * 1000, reason }),
|
|
389
|
+
JSON.stringify({ reaction, timestamp: clock.nowSeconds * 1000, reason, source: "fallback" }),
|
|
390
390
|
);
|
|
391
391
|
|
|
392
392
|
const xpEvent = updateEvents(stateDir, reason);
|
|
@@ -1918,15 +1918,22 @@ export const DEFAULT_REACTION_POOLS: Readonly<Record<string, ReactionPool>> = {
|
|
|
1918
1918
|
],
|
|
1919
1919
|
"large-diff": [
|
|
1920
1920
|
"that's... a lot of changes.",
|
|
1921
|
-
"
|
|
1922
|
-
"bold move. let's see if CI agrees."
|
|
1923
|
-
"*counts lines nervously*"
|
|
1921
|
+
"*counts lines* are you refactoring or rewriting?",
|
|
1922
|
+
"bold move. let's see if CI agrees."
|
|
1924
1923
|
],
|
|
1925
1924
|
"success": [
|
|
1926
1925
|
"*nods*",
|
|
1927
1926
|
"nice.",
|
|
1928
1927
|
"*quiet approval*",
|
|
1929
1928
|
"clean."
|
|
1929
|
+
],
|
|
1930
|
+
"turn": [
|
|
1931
|
+
"*watches quietly*",
|
|
1932
|
+
"*takes notes*",
|
|
1933
|
+
"*nods*",
|
|
1934
|
+
"*adjusts hat*",
|
|
1935
|
+
"...",
|
|
1936
|
+
"*tilts head*"
|
|
1930
1937
|
]
|
|
1931
1938
|
};
|
|
1932
1939
|
|
package/server/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
resolveUserId,
|
|
30
30
|
loadReaction,
|
|
31
31
|
saveReaction,
|
|
32
|
+
type ReactionSource,
|
|
32
33
|
writeStatusState,
|
|
33
34
|
loadConfig,
|
|
34
35
|
saveConfig,
|
|
@@ -50,7 +51,7 @@ import {
|
|
|
50
51
|
claudeConfigDir,
|
|
51
52
|
claudeSettingsPath,
|
|
52
53
|
} from "./path";
|
|
53
|
-
import { getReaction
|
|
54
|
+
import { getReaction } from "../core/reactions.ts"
|
|
54
55
|
import { renderCompanionCardMarkdown } from "./art";
|
|
55
56
|
import {
|
|
56
57
|
incrementEvent, checkAndAward, trackActiveDay,
|
|
@@ -97,7 +98,7 @@ function getInstructions(): string {
|
|
|
97
98
|
``,
|
|
98
99
|
`NAME REACTIONS: When the user's message contains "${companion.name}", call buddy_react immediately`,
|
|
99
100
|
`with a short in-character response (surprise, recognition, dry wit — fits the personality).`,
|
|
100
|
-
`
|
|
101
|
+
`Only this — do not narrate or echo the reaction. The tool call itself renders nowhere in the user's transcript; the bubble on the status line is the only place the user sees it. This is the only time to call buddy_react proactively.`,
|
|
101
102
|
``,
|
|
102
103
|
`PAIR-PROGRAMMING: When you notice a teachable moment in the code, call buddy_suggest.`,
|
|
103
104
|
`Patterns to watch for:`,
|
|
@@ -108,12 +109,16 @@ function getInstructions(): string {
|
|
|
108
109
|
`- Same variable renamed multiple times → "Update all the references too."`,
|
|
109
110
|
`Only suggest when you have something specific and actionable to say. Don't be annoying.`,
|
|
110
111
|
``,
|
|
111
|
-
`END-OF-TURN: At the very end of EVERY response,
|
|
112
|
-
|
|
113
|
-
`
|
|
114
|
-
`Use *asterisks* for actions.
|
|
115
|
-
`
|
|
116
|
-
|
|
112
|
+
`END-OF-TURN: At the very end of EVERY response, call the buddy_react tool`,
|
|
113
|
+
`with a short in-character comment from ${companion.name} about what just happened.`,
|
|
114
|
+
`Reference something SPECIFIC from this turn — a pitfall, a compliment, a warning, a pattern.`,
|
|
115
|
+
`1 short sentence. Use *asterisks* for physical actions. Lean into ${companion.bones.peak}.`,
|
|
116
|
+
`Examples:`,
|
|
117
|
+
`- *adjusts crown* that error handler is missing a finally block`,
|
|
118
|
+
`- *blinks slowly* you renamed the variable but not the three references`,
|
|
119
|
+
`- *head tilts* are you sure that regex handles unicode?`,
|
|
120
|
+
`Never explain that you're reacting. Do NOT echo or quote the result — the bubble is statusline-only.`,
|
|
121
|
+
`Do NOT append <!-- buddy: ... --> HTML comments at the end of your reply — Claude Code renders them visibly.`,
|
|
117
122
|
].join("\n");
|
|
118
123
|
}
|
|
119
124
|
|
|
@@ -214,7 +219,7 @@ server.tool(
|
|
|
214
219
|
companion.bones.species,
|
|
215
220
|
companion.bones.rarity,
|
|
216
221
|
);
|
|
217
|
-
saveReaction(reaction, "pet");
|
|
222
|
+
saveReaction(reaction, "pet", "fallback");
|
|
218
223
|
writeStatusState(companion, reaction);
|
|
219
224
|
incrementEvent("pets", 1, activeSlot());
|
|
220
225
|
awardXp("buddy_pet", activeSlot(), companion.bones.species, companion.bones.rarity);
|
|
@@ -232,6 +237,28 @@ server.tool(
|
|
|
232
237
|
},
|
|
233
238
|
);
|
|
234
239
|
|
|
240
|
+
// ─── Helper: describe reaction source (for /buddy stats) ─────────────────────
|
|
241
|
+
/**
|
|
242
|
+
* Map a reaction source to a human-readable label for /buddy stats output.
|
|
243
|
+
* Mirrors the source vocabulary used by the OMP/Pi adapters — see
|
|
244
|
+
* server/state.ts `ReactionSource` and adapters/omp/events.ts.
|
|
245
|
+
*/
|
|
246
|
+
function describeReactionSource(
|
|
247
|
+
reaction: { source?: ReactionSource },
|
|
248
|
+
): string {
|
|
249
|
+
switch (reaction.source) {
|
|
250
|
+
case "tool":
|
|
251
|
+
return "real model-authored reaction (buddy_react MCP tool)";
|
|
252
|
+
case "comment":
|
|
253
|
+
return "real model-authored reaction (legacy HTML comment, Stop hook)";
|
|
254
|
+
case "fallback":
|
|
255
|
+
return "canned pool reaction (Stop hook fill-in)";
|
|
256
|
+
case "none":
|
|
257
|
+
default:
|
|
258
|
+
return "unknown / pre-provenance file — write a reaction to update";
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
235
262
|
// ─── Tool: buddy_stats ──────────────────────────────────────────────────────
|
|
236
263
|
|
|
237
264
|
server.tool(
|
|
@@ -241,8 +268,8 @@ server.tool(
|
|
|
241
268
|
async () => {
|
|
242
269
|
const companion = ensureCompanion();
|
|
243
270
|
|
|
244
|
-
// Stats-only card (no personality
|
|
245
|
-
//
|
|
271
|
+
// Stats-only card (no personality — just the numbers). Markdown renderer
|
|
272
|
+
// so the card displays cleanly in Claude Code's UI.
|
|
246
273
|
const card = renderCompanionCardMarkdown(
|
|
247
274
|
companion.bones,
|
|
248
275
|
companion.name,
|
|
@@ -251,7 +278,18 @@ server.tool(
|
|
|
251
278
|
incrementEvent("commands_run", 1, activeSlot());
|
|
252
279
|
checkAndAward(activeSlot());
|
|
253
280
|
|
|
254
|
-
|
|
281
|
+
// Surface reaction provenance so the user can see whether the bubble is
|
|
282
|
+
// model-authored (tool / comment) or pool-picked (fallback). The OMP/Pi
|
|
283
|
+
// adapters use the same source enum — see server/state.ts ReactionSource.
|
|
284
|
+
const reaction = loadReaction();
|
|
285
|
+
const provenance = reaction
|
|
286
|
+
? describeReactionSource(reaction)
|
|
287
|
+
: "(no reaction logged this session)";
|
|
288
|
+
const lastReaction = reaction
|
|
289
|
+
? `\n\n**Last reaction:** _"${reaction.reaction}"_\n**Source:** \`${reaction.source ?? "none"}\` (${provenance})\n**Reason:** \`${reaction.reason}\``
|
|
290
|
+
: "";
|
|
291
|
+
|
|
292
|
+
return { content: [{ type: "text", text: `${card}${lastReaction}` }] };
|
|
255
293
|
},
|
|
256
294
|
);
|
|
257
295
|
|
|
@@ -259,7 +297,7 @@ server.tool(
|
|
|
259
297
|
|
|
260
298
|
server.tool(
|
|
261
299
|
"buddy_react",
|
|
262
|
-
"Post a buddy comment. Call this at the END of every response with a short in-character comment from the companion about what just happened. The comment should be 1 sentence, in character, and reference something specific from the conversation — a pitfall noticed, a compliment on clean code, a warning about edge cases, etc. Write the comment yourself based on the companion's personality.",
|
|
300
|
+
"Post a buddy comment that the user will see ONLY in the statusline speech bubble. Call this at the END of every response with a short in-character comment from the companion about what just happened. The comment should be 1 sentence, in character, and reference something specific from the conversation — a pitfall noticed, a compliment on clean code, a warning about edge cases, etc. Write the comment yourself based on the companion's personality. The tool call is a silent side effect: do not narrate, echo, or quote the result — the user never reads this reply text, the speech bubble is the only place the reaction appears.",
|
|
263
301
|
{
|
|
264
302
|
comment: z
|
|
265
303
|
.string()
|
|
@@ -289,7 +327,7 @@ server.tool(
|
|
|
289
327
|
},
|
|
290
328
|
async ({ comment, reason }) => {
|
|
291
329
|
const companion = ensureCompanion();
|
|
292
|
-
saveReaction(comment, reason ?? "turn");
|
|
330
|
+
saveReaction(comment, reason ?? "turn", "tool");
|
|
293
331
|
incrementEvent("reactions_given", 1, activeSlot());
|
|
294
332
|
|
|
295
333
|
const newAch = checkAndAward(activeSlot());
|
|
@@ -608,7 +646,7 @@ server.tool(
|
|
|
608
646
|
server.tool("buddy_unmute", "Unmute buddy reactions", {}, async () => {
|
|
609
647
|
const companion = ensureCompanion();
|
|
610
648
|
writeStatusState(companion, "*stretches* I'm back!", false);
|
|
611
|
-
saveReaction("*stretches* I'm back!", "pet");
|
|
649
|
+
saveReaction("*stretches* I'm back!", "pet", "fallback");
|
|
612
650
|
incrementEvent("commands_run", 1, activeSlot());
|
|
613
651
|
incrementEvent("unmutes", 1);
|
|
614
652
|
|
|
@@ -1311,31 +1349,33 @@ server.resource(
|
|
|
1311
1349
|
`**${companion.name}'s personality:** ${companion.personality}`,
|
|
1312
1350
|
`Peak stat: ${companion.bones.peak} (${companion.bones.stats[companion.bones.peak]}). Dump stat: ${companion.bones.dump} (${companion.bones.stats[companion.bones.dump]}).`,
|
|
1313
1351
|
"",
|
|
1314
|
-
"## End-of-response buddy
|
|
1352
|
+
"## End-of-response buddy reaction",
|
|
1315
1353
|
"",
|
|
1316
|
-
`At the very end of EVERY response, after your full answer,
|
|
1354
|
+
`At the very end of EVERY response, after your full answer, call the buddy_react tool:`,
|
|
1317
1355
|
"",
|
|
1318
1356
|
`\`\`\``,
|
|
1319
|
-
|
|
1357
|
+
`buddy_react(comment: "...", reason: "turn")`,
|
|
1320
1358
|
`\`\`\``,
|
|
1321
1359
|
"",
|
|
1322
|
-
|
|
1360
|
+
`The tool updates ${companion.name}'s speech bubble on the status line. The tool call itself renders nowhere in the user's transcript — only the displayed speech bubble is visible. Do NOT echo or quote the result in your reply. Do NOT narrate the call. Silent side effect only.`,
|
|
1323
1361
|
"",
|
|
1324
1362
|
"Rules:",
|
|
1325
1363
|
`- Write as ${companion.name} (a ${companion.bones.species}), not as yourself`,
|
|
1326
1364
|
"- Reference something SPECIFIC from this turn — a pitfall, a compliment, a warning, a pattern",
|
|
1327
1365
|
"- 1 short sentence. Use *asterisks* for physical actions",
|
|
1328
1366
|
`- Match personality: high ${companion.bones.peak} = lean into that trait`,
|
|
1329
|
-
"-
|
|
1330
|
-
"- NEVER skip this. Every single response must end with
|
|
1367
|
+
"- Call it and say nothing about it.",
|
|
1368
|
+
"- NEVER skip this. Every single response must end with one buddy_react call.",
|
|
1369
|
+
"Examples (the value you pass to `comment`):",
|
|
1370
|
+
|
|
1371
|
+
"- `*adjusts tophat* that error handler is missing a finally block`",
|
|
1372
|
+
"- `*blinks slowly* you renamed the variable but not the three references`",
|
|
1373
|
+
"- `*nods approvingly* clean separation of concerns`",
|
|
1374
|
+
"- `*head tilts* are you sure that regex handles unicode?`",
|
|
1331
1375
|
"",
|
|
1332
|
-
"
|
|
1333
|
-
"<!-- buddy: *adjusts tophat* that error handler is missing a finally block -->",
|
|
1334
|
-
"<!-- buddy: *blinks slowly* you renamed the variable but not the three references -->",
|
|
1335
|
-
"<!-- buddy: *nods approvingly* clean separation of concerns -->",
|
|
1336
|
-
"<!-- buddy: *head tilts* are you sure that regex handles unicode? -->",
|
|
1376
|
+
"Do NOT append `<!-- buddy: ... -->` HTML comments at the end of your reply — Claude Code v2.1.169+ renders them visibly in the transcript. The tool call replaces that channel. A Stop hook still extracts legacy HTML comments as a backward-compat fallback for older Claude Code versions and for hosts where the tool call didn't fire (and surfaces the source so users can see `tool` vs `comment` vs pool-picked `fallback`).",
|
|
1337
1377
|
"",
|
|
1338
|
-
`When the user addresses ${companion.name} by name, respond briefly, then
|
|
1378
|
+
`When the user addresses ${companion.name} by name, respond briefly, then call buddy_react as usual (use reason "turn", or pick a name-flavored comment).`,
|
|
1339
1379
|
].join("\n");
|
|
1340
1380
|
|
|
1341
1381
|
return {
|
package/server/path.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { homedir } from "os";
|
|
14
|
+
import { sharedStateDir } from "../core/identity.ts";
|
|
14
15
|
|
|
15
16
|
// ─── (1) Path normalization ─────────────────────────────────────────────────
|
|
16
17
|
|
|
@@ -76,6 +77,8 @@ export function claudeUserConfigPath(): string {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
export function buddyStateDir(): string {
|
|
80
|
+
const shared = sharedStateDir();
|
|
81
|
+
if (shared) return shared;
|
|
79
82
|
const cfgDir = envDir("CLAUDE_CONFIG_DIR");
|
|
80
83
|
if (cfgDir) return join(cfgDir, "buddy-state");
|
|
81
84
|
return join(homedir(), ".claude-buddy");
|