@ramarivera/coding-buddy 0.7.3 → 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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- 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/hooks/file-type-react.sh +1 -1
- package/hooks/mood-react.sh +1 -1
- package/hooks/name-react.sh +1 -1
- package/hooks/react.sh +1 -1
- package/package.json +6 -2
- package/scripts/ci/Dockerfile.vhs +125 -0
- package/scripts/ci/check-statusline.sh +338 -0
- 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/statusline-fixture/buddy-state/config.json +1 -0
- package/scripts/ci/statusline-fixture/buddy-state/status.json +39 -0
- package/scripts/ci/statusline.tape +9 -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/common.ts +33 -0
- package/server/hooks/file-type-react.ts +147 -0
- package/server/hooks/mood-react.ts +107 -0
- package/server/hooks/name-react.ts +98 -0
- package/server/hooks/react.ts +413 -0
- package/server/hooks/reaction-data.ts +2722 -0
- package/server/index.ts +67 -27
- package/server/path.ts +3 -0
- package/server/state.ts +68 -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 +73 -10
- package/hooks/legacy/file-type-react.sh +0 -403
- package/hooks/legacy/mood-react.sh +0 -210
- package/hooks/legacy/name-react.sh +0 -178
- package/hooks/legacy/react.sh +0 -1364
- package/server/hooks/legacy-bash.ts +0 -34
|
@@ -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) {
|
package/server/hooks/common.ts
CHANGED
|
@@ -6,13 +6,23 @@ import { buddyStateDir } from "../path.ts";
|
|
|
6
6
|
export type JsonObject = Record<string, unknown>;
|
|
7
7
|
|
|
8
8
|
export interface HookRuntime {
|
|
9
|
+
clock?: () => HookClock;
|
|
9
10
|
now?: () => number;
|
|
10
11
|
packageRoot?: string;
|
|
12
|
+
random?: () => number;
|
|
11
13
|
sessionId?: string;
|
|
12
14
|
spawnDetached?: (script: string, args: string[]) => void;
|
|
13
15
|
stateDir?: string;
|
|
14
16
|
}
|
|
15
17
|
|
|
18
|
+
export interface HookClock {
|
|
19
|
+
day: number;
|
|
20
|
+
dayOfWeek: number;
|
|
21
|
+
hour: number;
|
|
22
|
+
month: number;
|
|
23
|
+
nowSeconds: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
16
26
|
export function parseHookInput(raw: string): JsonObject | null {
|
|
17
27
|
try {
|
|
18
28
|
const value = JSON.parse(raw) as unknown;
|
|
@@ -56,6 +66,29 @@ export function nonNegativeInteger(value: unknown, fallback: number): number {
|
|
|
56
66
|
return fallback;
|
|
57
67
|
}
|
|
58
68
|
|
|
69
|
+
export function randomIndex(length: number, runtime: HookRuntime = {}): number {
|
|
70
|
+
return Math.floor((runtime.random?.() ?? Math.random()) * length);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function pickRandom<T>(values: readonly T[], runtime: HookRuntime = {}): T | undefined {
|
|
74
|
+
if (values.length === 0) return undefined;
|
|
75
|
+
return values[randomIndex(values.length, runtime)];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function hookClock(runtime: HookRuntime = {}): HookClock {
|
|
79
|
+
if (runtime.clock) return runtime.clock();
|
|
80
|
+
const nowMs = runtime.now?.() ?? Date.now();
|
|
81
|
+
const date = new Date(nowMs);
|
|
82
|
+
const jsDay = date.getDay();
|
|
83
|
+
return {
|
|
84
|
+
day: date.getDate(),
|
|
85
|
+
dayOfWeek: jsDay === 0 ? 7 : jsDay,
|
|
86
|
+
hour: date.getHours(),
|
|
87
|
+
month: date.getMonth() + 1,
|
|
88
|
+
nowSeconds: Math.floor(nowMs / 1000),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
59
92
|
export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
|
|
60
93
|
try {
|
|
61
94
|
const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { fileReactionPool } from "./reaction-data.ts";
|
|
6
|
+
import {
|
|
7
|
+
fileExists,
|
|
8
|
+
isOnCooldown,
|
|
9
|
+
nonNegativeInteger,
|
|
10
|
+
parseHookInput,
|
|
11
|
+
pickRandom,
|
|
12
|
+
readJsonFile,
|
|
13
|
+
readStdin,
|
|
14
|
+
resolveHookSessionId,
|
|
15
|
+
resolveHookStateDir,
|
|
16
|
+
type HookRuntime,
|
|
17
|
+
} from "./common.ts";
|
|
18
|
+
|
|
19
|
+
interface BuddyStatus {
|
|
20
|
+
muted?: unknown;
|
|
21
|
+
species?: unknown;
|
|
22
|
+
reaction?: unknown;
|
|
23
|
+
[key: string]: unknown;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FileConfig {
|
|
27
|
+
commentCooldown?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FileTypeReactResult {
|
|
31
|
+
fileType?: string;
|
|
32
|
+
reaction?: string;
|
|
33
|
+
updated: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const FILE_TYPE_PATTERNS: ReadonlyArray<readonly [string, string]> = [
|
|
37
|
+
["readme", "*/README*|*/CHANGELOG*|*/CONTRIBUTING*|*/LICENSE*"],
|
|
38
|
+
["package-file", "*/package.json|*/Cargo.toml|*/go.mod|*/go.sum|*/pyproject.toml|*/requirements*.txt"],
|
|
39
|
+
["ci-file", "*/.github/workflows/*|*/Jenkinsfile*|*/.gitlab-ci*|*.ci.yml|*.ci.yaml"],
|
|
40
|
+
["gitignore", "*/.gitignore|*/.dockerignore|*/.eslintignore|*/.prettierignore|.gitignore|.dockerignore|.eslintignore|.prettierignore"],
|
|
41
|
+
["env-file", "*/.env*|.env*"],
|
|
42
|
+
["makefile", "*/Makefile*|Makefile*|*.mk"],
|
|
43
|
+
["docker-file", "*/Dockerfile*|Dockerfile*|*/docker-compose*|docker-compose*|*.dockerfile"],
|
|
44
|
+
["regex-file", "*.regex|*.pattern"],
|
|
45
|
+
["css-file", "*.css|*.scss|*.less|*.sass"],
|
|
46
|
+
["sql-file", "*.sql|*migration*"],
|
|
47
|
+
["lock-file", "*.lock|*/package-lock.json|*/bun.lockb|*/yarn.lock|*/pnpm-lock.yaml"],
|
|
48
|
+
["test-file", "*.test.*|*.spec.*|*_test.*|*test_*"],
|
|
49
|
+
["doc-file", "*.md|*.rst|*.txt|*.adoc"],
|
|
50
|
+
["config-file", "*.json|*.yaml|*.yml|*.toml|*.ini|*.conf"],
|
|
51
|
+
["binary-file", "*.png|*.jpg|*.gif|*.woff*|*.ico|*.exe|*.so|*.dylib|*.bin"],
|
|
52
|
+
["proto-file", "*.proto|*.graphql|*.gql"],
|
|
53
|
+
["lang-python", "*.py"],
|
|
54
|
+
["lang-typescript", "*.ts|*.tsx"],
|
|
55
|
+
["lang-javascript", "*.js|*.jsx"],
|
|
56
|
+
["lang-rust", "*.rs"],
|
|
57
|
+
["lang-go", "*.go"],
|
|
58
|
+
["lang-java", "*.java"],
|
|
59
|
+
["lang-ruby", "*.rb"],
|
|
60
|
+
["lang-php", "*.php"],
|
|
61
|
+
["lang-c", "*.c|*.h"],
|
|
62
|
+
["lang-cpp", "*.cpp|*.hpp"],
|
|
63
|
+
["lang-swift", "*.swift"],
|
|
64
|
+
["lang-kotlin", "*.kt"],
|
|
65
|
+
["lang-haskell", "*.hs"],
|
|
66
|
+
["lang-elixir", "*.ex|*.exs"],
|
|
67
|
+
["lang-zig", "*.zig"],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
function shellGlob(pattern: string): RegExp {
|
|
71
|
+
let source = "^";
|
|
72
|
+
for (const character of pattern) {
|
|
73
|
+
if (character === "*") source += ".*";
|
|
74
|
+
else if (character === "?") source += ".";
|
|
75
|
+
else source += character.replace(/[\\^$.*+()[\]{}|]/g, "\\$&");
|
|
76
|
+
}
|
|
77
|
+
return new RegExp(`${source}$`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function matchesAnyPattern(path: string, patterns: string): boolean {
|
|
81
|
+
return patterns.split("|").some((pattern) => shellGlob(pattern).test(path));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function fileTypeForPath(filePath: string): string {
|
|
85
|
+
for (const [fileType, patterns] of FILE_TYPE_PATTERNS) {
|
|
86
|
+
if (matchesAnyPattern(filePath, patterns)) return fileType;
|
|
87
|
+
}
|
|
88
|
+
return "";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function textValue(value: unknown): string {
|
|
92
|
+
if (typeof value === "string") return value;
|
|
93
|
+
if (value === null || value === undefined) return "";
|
|
94
|
+
return typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function handleFileTypeReact(rawInput: string, runtime: HookRuntime = {}): FileTypeReactResult {
|
|
98
|
+
const stateDir = resolveHookStateDir(runtime);
|
|
99
|
+
const statusFile = join(stateDir, "status.json");
|
|
100
|
+
if (!fileExists(statusFile)) return { updated: false };
|
|
101
|
+
|
|
102
|
+
const config = readJsonFile<FileConfig>(join(stateDir, "config.json")) ?? {};
|
|
103
|
+
const cooldown = nonNegativeInteger(config.commentCooldown, 30);
|
|
104
|
+
const sid = resolveHookSessionId(runtime);
|
|
105
|
+
const now = runtime.now?.() ?? Date.now();
|
|
106
|
+
if (isOnCooldown(join(stateDir, `.last_reaction.${sid}`), cooldown, now)) {
|
|
107
|
+
return { updated: false };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
|
|
111
|
+
const status = parsedStatus ?? {};
|
|
112
|
+
if (status.muted === true || status.muted === "true") return { updated: false };
|
|
113
|
+
|
|
114
|
+
const input = parseHookInput(rawInput);
|
|
115
|
+
if (!input) return { updated: false };
|
|
116
|
+
const filePath = textValue(input.file_path);
|
|
117
|
+
if (!filePath) return { updated: false };
|
|
118
|
+
|
|
119
|
+
if ((runtime.random?.() ?? Math.random()) * 100 >= 15) return { updated: false };
|
|
120
|
+
|
|
121
|
+
let fileType = fileTypeForPath(filePath);
|
|
122
|
+
if (!fileType) return { updated: false };
|
|
123
|
+
if (fileType === "lang-javascript") fileType = "lang-typescript";
|
|
124
|
+
|
|
125
|
+
const species = textValue(status.species) || "blob";
|
|
126
|
+
const reaction = pickRandom(fileReactionPool(species, fileType), runtime);
|
|
127
|
+
if (!reaction) return { updated: false };
|
|
128
|
+
|
|
129
|
+
mkdirSync(stateDir, { recursive: true });
|
|
130
|
+
writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(Math.floor(now / 1000)));
|
|
131
|
+
writeFileSync(
|
|
132
|
+
join(stateDir, `reaction.${sid}.json`),
|
|
133
|
+
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType, source: "fallback" }),
|
|
134
|
+
);
|
|
135
|
+
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
|
|
136
|
+
|
|
137
|
+
return { fileType, reaction, updated: true };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (import.meta.main) {
|
|
141
|
+
try {
|
|
142
|
+
handleFileTypeReact(await readStdin());
|
|
143
|
+
} catch {
|
|
144
|
+
// Claude Code hooks must never fail the turn.
|
|
145
|
+
}
|
|
146
|
+
process.exit(0);
|
|
147
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { moodReactionPool } from "./reaction-data.ts";
|
|
6
|
+
import {
|
|
7
|
+
fileExists,
|
|
8
|
+
isOnCooldown,
|
|
9
|
+
nonNegativeInteger,
|
|
10
|
+
parseHookInput,
|
|
11
|
+
pickRandom,
|
|
12
|
+
readJsonFile,
|
|
13
|
+
readStdin,
|
|
14
|
+
resolveHookSessionId,
|
|
15
|
+
resolveHookStateDir,
|
|
16
|
+
stringField,
|
|
17
|
+
type HookRuntime,
|
|
18
|
+
} from "./common.ts";
|
|
19
|
+
|
|
20
|
+
interface BuddyStatus {
|
|
21
|
+
muted?: unknown;
|
|
22
|
+
species?: unknown;
|
|
23
|
+
reaction?: unknown;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface MoodConfig {
|
|
28
|
+
moodCooldown?: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Events {
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface MoodReactResult {
|
|
36
|
+
mood?: "frustrated" | "happy" | "stuck";
|
|
37
|
+
reaction?: string;
|
|
38
|
+
updated: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MOOD_PATTERNS: ReadonlyArray<readonly [MoodReactResult["mood"], RegExp]> = [
|
|
42
|
+
["frustrated", /\bwtf\b|\bugh\b|\bstupid\b|\bbroken\b|why won.?t|\bcome on\b|\bseriously\b|\bdamn\b|\bhell\b|this sucks|hate this|\bgrr\b|\bargh\b|\bannoying\b|\bfrustrat\b|\bterrible\b|\bhorrible\b|\bworst\b/i],
|
|
43
|
+
["happy", /\bnice!?\b|\bworks!?\b|\bawesome\b|\bperfect\b|\bgreat\b|love it|\byay\b|\bsweet\b|\bbeautiful\b|\bamazing\b|hell yes|nailed it|fixed it|\bhell yeah\b|\bfantastic\b/i],
|
|
44
|
+
["stuck", /\bstuck\b|\bhelp\b|\bconfused\b|don.?t understand|how do i\b|what does\b|why is\b|i can.?t\b|no idea|\blost\b|\bclueless\b|\bbaffled\b|\bpuzzled\b/i],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function moodForPrompt(prompt: string): MoodReactResult["mood"] {
|
|
48
|
+
return MOOD_PATTERNS.find(([, pattern]) => pattern.test(prompt))?.[0];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function incrementEvent(stateDir: string, key: string): void {
|
|
52
|
+
const eventsFile = join(stateDir, "events.json");
|
|
53
|
+
const parsedEvents = readJsonFile<Events>(eventsFile);
|
|
54
|
+
if (!parsedEvents && fileExists(eventsFile)) return;
|
|
55
|
+
const events = parsedEvents ?? {};
|
|
56
|
+
events[key] = typeof events[key] === "number" ? Number(events[key]) + 1 : 1;
|
|
57
|
+
writeFileSync(eventsFile, JSON.stringify(events, null, 2));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function handleMoodReact(rawInput: string, runtime: HookRuntime = {}): MoodReactResult {
|
|
61
|
+
const stateDir = resolveHookStateDir(runtime);
|
|
62
|
+
const statusFile = join(stateDir, "status.json");
|
|
63
|
+
if (!fileExists(statusFile)) return { updated: false };
|
|
64
|
+
|
|
65
|
+
const config = readJsonFile<MoodConfig>(join(stateDir, "config.json")) ?? {};
|
|
66
|
+
const cooldown = nonNegativeInteger(config.moodCooldown, 60);
|
|
67
|
+
const sid = resolveHookSessionId(runtime);
|
|
68
|
+
const now = runtime.now?.() ?? Date.now();
|
|
69
|
+
if (isOnCooldown(join(stateDir, `.last_mood.${sid}`), cooldown, now)) {
|
|
70
|
+
return { updated: false };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const input = parseHookInput(rawInput);
|
|
74
|
+
if (!input) return { updated: false };
|
|
75
|
+
const prompt = stringField(input, "prompt");
|
|
76
|
+
if (!prompt) return { updated: false };
|
|
77
|
+
|
|
78
|
+
const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
|
|
79
|
+
const status = parsedStatus ?? {};
|
|
80
|
+
if (status.muted === true || status.muted === "true") return { updated: false };
|
|
81
|
+
|
|
82
|
+
const mood = moodForPrompt(prompt);
|
|
83
|
+
if (!mood) return { updated: false };
|
|
84
|
+
const species = typeof status.species === "string" ? status.species : "blob";
|
|
85
|
+
const reaction = pickRandom(moodReactionPool(species, mood), runtime);
|
|
86
|
+
if (!reaction) return { updated: false };
|
|
87
|
+
|
|
88
|
+
mkdirSync(stateDir, { recursive: true });
|
|
89
|
+
writeFileSync(join(stateDir, `.last_mood.${sid}`), String(Math.floor(now / 1000)));
|
|
90
|
+
writeFileSync(
|
|
91
|
+
join(stateDir, `reaction.${sid}.json`),
|
|
92
|
+
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: mood, source: "fallback" }),
|
|
93
|
+
);
|
|
94
|
+
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
|
|
95
|
+
incrementEvent(stateDir, `mood_${mood}`);
|
|
96
|
+
|
|
97
|
+
return { mood, reaction, updated: true };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (import.meta.main) {
|
|
101
|
+
try {
|
|
102
|
+
handleMoodReact(await readStdin());
|
|
103
|
+
} catch {
|
|
104
|
+
// Claude Code hooks must never fail the turn.
|
|
105
|
+
}
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_NAME_REACTIONS,
|
|
7
|
+
NAME_REACTION_POOLS,
|
|
8
|
+
} from "./reaction-data.ts";
|
|
9
|
+
import {
|
|
10
|
+
fileExists,
|
|
11
|
+
parseHookInput,
|
|
12
|
+
pickRandom,
|
|
13
|
+
readJsonFile,
|
|
14
|
+
readStdin,
|
|
15
|
+
resolveHookSessionId,
|
|
16
|
+
resolveHookStateDir,
|
|
17
|
+
type JsonObject,
|
|
18
|
+
type HookRuntime,
|
|
19
|
+
} from "./common.ts";
|
|
20
|
+
|
|
21
|
+
interface BuddyStatus {
|
|
22
|
+
muted?: unknown;
|
|
23
|
+
name?: unknown;
|
|
24
|
+
species?: unknown;
|
|
25
|
+
reaction?: unknown;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface NameReactResult {
|
|
30
|
+
reaction?: string;
|
|
31
|
+
updated: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function promptFromInput(input: JsonObject): string {
|
|
35
|
+
const direct = input.prompt ?? input.message ?? input.user_message;
|
|
36
|
+
if (direct !== undefined && direct !== null) {
|
|
37
|
+
return typeof direct === "string" ? direct : String(direct);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const messages = input.messages;
|
|
41
|
+
if (!Array.isArray(messages) || messages.length === 0) return "";
|
|
42
|
+
const content = (messages[messages.length - 1] as JsonObject | null)?.content;
|
|
43
|
+
if (Array.isArray(content)) {
|
|
44
|
+
const first = content[0];
|
|
45
|
+
return first && typeof first === "object" ? String((first as JsonObject).text ?? "") : "";
|
|
46
|
+
}
|
|
47
|
+
return typeof content === "string" ? content : "";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function hasNameMention(prompt: string, name: string): boolean {
|
|
51
|
+
if (!name) return false;
|
|
52
|
+
try {
|
|
53
|
+
return new RegExp(`(^|[^a-zA-Z])${name}([^a-zA-Z]|$)`, "i").test(prompt);
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function handleNameReact(rawInput: string, runtime: HookRuntime = {}): NameReactResult {
|
|
60
|
+
const stateDir = resolveHookStateDir(runtime);
|
|
61
|
+
const statusFile = join(stateDir, "status.json");
|
|
62
|
+
if (!fileExists(statusFile)) return { updated: false };
|
|
63
|
+
|
|
64
|
+
const input = parseHookInput(rawInput);
|
|
65
|
+
if (!input) return { updated: false };
|
|
66
|
+
const prompt = promptFromInput(input);
|
|
67
|
+
if (!prompt) return { updated: false };
|
|
68
|
+
|
|
69
|
+
const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
|
|
70
|
+
const status = parsedStatus ?? {};
|
|
71
|
+
const name = typeof status.name === "string" ? status.name : "";
|
|
72
|
+
if (!hasNameMention(prompt, name)) return { updated: false };
|
|
73
|
+
if (status.muted === true || status.muted === "true") return { updated: false };
|
|
74
|
+
|
|
75
|
+
const species = typeof status.species === "string" ? status.species : "blob";
|
|
76
|
+
const reaction = pickRandom(NAME_REACTION_POOLS[species] ?? DEFAULT_NAME_REACTIONS, runtime);
|
|
77
|
+
if (!reaction) return { updated: false };
|
|
78
|
+
|
|
79
|
+
const now = runtime.now?.() ?? Date.now();
|
|
80
|
+
const sid = resolveHookSessionId(runtime);
|
|
81
|
+
mkdirSync(stateDir, { recursive: true });
|
|
82
|
+
if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
|
|
83
|
+
writeFileSync(
|
|
84
|
+
join(stateDir, `reaction.${sid}.json`),
|
|
85
|
+
JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: "name", source: "fallback" }),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
return { reaction, updated: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (import.meta.main) {
|
|
92
|
+
try {
|
|
93
|
+
handleNameReact(await readStdin());
|
|
94
|
+
} catch {
|
|
95
|
+
// Claude Code hooks must never fail the turn.
|
|
96
|
+
}
|
|
97
|
+
process.exit(0);
|
|
98
|
+
}
|