@ramarivera/coding-buddy 0.4.0-alpha.3 → 0.4.0-alpha.5
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/adapters/claude/install/backup.ts +7 -6
- package/adapters/claude/install/disable.ts +5 -4
- package/adapters/claude/install/doctor.ts +14 -8
- package/adapters/claude/install/install.ts +19 -16
- package/adapters/claude/install/test-statusline.ts +3 -2
- package/adapters/claude/install/uninstall.ts +9 -8
- package/adapters/claude/plugin/marketplace.json +2 -2
- package/adapters/claude/plugin/plugin.json +1 -1
- package/adapters/claude/server/index.ts +1 -1
- package/adapters/claude/storage/identity.ts +2 -3
- package/adapters/claude/storage/paths.ts +24 -0
- package/adapters/claude/storage/settings.ts +2 -3
- package/adapters/pi/commands.ts +1 -1
- package/adapters/pi/events.ts +123 -13
- package/core/command-service.ts +34 -0
- package/core/reactions.ts +55 -0
- package/package.json +1 -1
|
@@ -25,14 +25,15 @@ import {
|
|
|
25
25
|
readFileSync, writeFileSync, mkdirSync, existsSync,
|
|
26
26
|
readdirSync, statSync, rmSync, copyFileSync,
|
|
27
27
|
} from "fs";
|
|
28
|
-
import { join } from "path";
|
|
28
|
+
import { dirname, join } from "path";
|
|
29
29
|
import { homedir } from "os";
|
|
30
|
+
import { getBuddySkillDir, getClaudeJsonPath, getClaudeSettingsPath } from "../storage/paths.ts";
|
|
30
31
|
|
|
31
32
|
const HOME = homedir();
|
|
32
33
|
const BACKUPS_DIR = join(HOME, ".claude-buddy", "backups");
|
|
33
|
-
const SETTINGS =
|
|
34
|
-
const CLAUDE_JSON =
|
|
35
|
-
const SKILL = join(
|
|
34
|
+
const SETTINGS = getClaudeSettingsPath();
|
|
35
|
+
const CLAUDE_JSON = getClaudeJsonPath();
|
|
36
|
+
const SKILL = join(getBuddySkillDir(), "SKILL.md");
|
|
36
37
|
const STATE_DIR = join(HOME, ".claude-buddy");
|
|
37
38
|
|
|
38
39
|
const RED = "\x1b[31m";
|
|
@@ -193,7 +194,7 @@ function restoreBackup(ts: string) {
|
|
|
193
194
|
// 1. settings.json — overwrite
|
|
194
195
|
const settingsBak = join(dir, "settings.json");
|
|
195
196
|
if (existsSync(settingsBak)) {
|
|
196
|
-
mkdirSync(
|
|
197
|
+
mkdirSync(dirname(SETTINGS), { recursive: true });
|
|
197
198
|
copyFileSync(settingsBak, SETTINGS);
|
|
198
199
|
ok("Restored: ~/.claude/settings.json");
|
|
199
200
|
}
|
|
@@ -215,7 +216,7 @@ function restoreBackup(ts: string) {
|
|
|
215
216
|
// 3. SKILL.md
|
|
216
217
|
const skillBak = join(dir, "SKILL.md");
|
|
217
218
|
if (existsSync(skillBak)) {
|
|
218
|
-
mkdirSync(
|
|
219
|
+
mkdirSync(dirname(SKILL), { recursive: true });
|
|
219
220
|
copyFileSync(skillBak, SKILL);
|
|
220
221
|
ok("Restored: ~/.claude/skills/buddy/SKILL.md");
|
|
221
222
|
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { readFileSync, writeFileSync, existsSync } from "fs";
|
|
12
12
|
import { join } from "path";
|
|
13
13
|
import { homedir } from "os";
|
|
14
|
+
import { getClaudeJsonPath, getClaudeSettingsPath } from "../storage/paths.ts";
|
|
14
15
|
|
|
15
16
|
interface HookCommand {
|
|
16
17
|
type: "command";
|
|
@@ -42,8 +43,8 @@ function ok(msg: string) { console.log(`${GREEN}✓${NC} ${msg}`); }
|
|
|
42
43
|
function warn(msg: string) { console.log(`${YELLOW}⚠${NC} ${msg}`); }
|
|
43
44
|
|
|
44
45
|
const HOME = homedir();
|
|
45
|
-
const CLAUDE_JSON =
|
|
46
|
-
const SETTINGS =
|
|
46
|
+
const CLAUDE_JSON = getClaudeJsonPath();
|
|
47
|
+
const SETTINGS = getClaudeSettingsPath();
|
|
47
48
|
|
|
48
49
|
console.log(`\n${BOLD}Disabling claude-buddy...${NC}\n`);
|
|
49
50
|
|
|
@@ -54,12 +55,12 @@ try {
|
|
|
54
55
|
delete claudeJson.mcpServers["claude-buddy"];
|
|
55
56
|
if (Object.keys(claudeJson.mcpServers).length === 0) delete claudeJson.mcpServers;
|
|
56
57
|
writeFileSync(CLAUDE_JSON, JSON.stringify(claudeJson, null, 2));
|
|
57
|
-
ok(
|
|
58
|
+
ok(`MCP server removed from ${CLAUDE_JSON}`);
|
|
58
59
|
} else {
|
|
59
60
|
warn("MCP server was not registered");
|
|
60
61
|
}
|
|
61
62
|
} catch {
|
|
62
|
-
warn(
|
|
63
|
+
warn(`Could not update ${CLAUDE_JSON}`);
|
|
63
64
|
}
|
|
64
65
|
|
|
65
66
|
// 2. Remove status line + hooks from settings.json
|
|
@@ -12,9 +12,14 @@ import { readFileSync, existsSync, statSync } from "fs";
|
|
|
12
12
|
import { execSync } from "child_process";
|
|
13
13
|
import { join, resolve } from "path";
|
|
14
14
|
import { homedir } from "os";
|
|
15
|
+
import { getBuddySkillDir, getClaudeConfigDir, getClaudeJsonPath, getClaudeSettingsPath } from "../storage/paths.ts";
|
|
15
16
|
|
|
16
17
|
const PROJECT_ROOT = resolve(import.meta.dir, "../../..");
|
|
17
18
|
const HOME = homedir();
|
|
19
|
+
const CLAUDE_DIR = getClaudeConfigDir();
|
|
20
|
+
const CLAUDE_JSON = getClaudeJsonPath();
|
|
21
|
+
const SETTINGS = getClaudeSettingsPath();
|
|
22
|
+
const SKILL_PATH = join(getBuddySkillDir(), "SKILL.md");
|
|
18
23
|
const STATUS_SCRIPT = join(PROJECT_ROOT, "adapters", "claude", "statusline", "buddy-status.sh");
|
|
19
24
|
|
|
20
25
|
const RED = "\x1b[31m";
|
|
@@ -95,8 +100,10 @@ row("tput cols", tryExec("tput cols 2>/dev/null", "(failed)"));
|
|
|
95
100
|
section("Filesystem");
|
|
96
101
|
const procExists = existsSync("/proc");
|
|
97
102
|
row("/proc exists", procExists ? `${GREEN}yes${NC} (Linux)` : `${RED}no${NC} (macOS/BSD)`);
|
|
98
|
-
row("
|
|
99
|
-
row("
|
|
103
|
+
row("Claude config dir", CLAUDE_DIR);
|
|
104
|
+
row("Claude config dir exists", existsSync(CLAUDE_DIR) ? "yes" : "no");
|
|
105
|
+
row("Claude config file", CLAUDE_JSON);
|
|
106
|
+
row("Claude config file exists", existsSync(CLAUDE_JSON) ? "yes" : "no");
|
|
100
107
|
row("~/.claude-buddy/ exists", existsSync(join(HOME, ".claude-buddy")) ? "yes" : "no");
|
|
101
108
|
row("Project root", PROJECT_ROOT);
|
|
102
109
|
row("Status script exists", existsSync(STATUS_SCRIPT) ? "yes" : `${RED}no${NC}`);
|
|
@@ -138,8 +145,8 @@ if (isRecord(status)) {
|
|
|
138
145
|
// ─── settings.json ──────────────────────────────────────────────────────────
|
|
139
146
|
|
|
140
147
|
section("Claude Code config");
|
|
141
|
-
const settings = tryParseJson(tryRead(
|
|
142
|
-
const claudeJson = tryParseJson(tryRead(
|
|
148
|
+
const settings = tryParseJson(tryRead(SETTINGS));
|
|
149
|
+
const claudeJson = tryParseJson(tryRead(CLAUDE_JSON));
|
|
143
150
|
|
|
144
151
|
if (isRecord(settings) && "statusLine" in settings && settings.statusLine !== undefined) {
|
|
145
152
|
console.log(` ${DIM}statusLine:${NC}`);
|
|
@@ -166,11 +173,10 @@ if (isRecord(claudeJson) && isRecord(claudeJson.mcpServers) && isRecord(claudeJs
|
|
|
166
173
|
err("MCP server NOT registered in ~/.claude.json");
|
|
167
174
|
}
|
|
168
175
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
ok(`Skill installed: ${skillPath}`);
|
|
176
|
+
if (existsSync(SKILL_PATH)) {
|
|
177
|
+
ok(`Skill installed: ${SKILL_PATH}`);
|
|
172
178
|
} else {
|
|
173
|
-
err(`Skill missing: ${
|
|
179
|
+
err(`Skill missing: ${SKILL_PATH}`);
|
|
174
180
|
}
|
|
175
181
|
|
|
176
182
|
// ─── Live status line test ──────────────────────────────────────────────────
|
|
@@ -5,15 +5,15 @@
|
|
|
5
5
|
* Checks: bun, jq, ~/.claude/ directory
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
|
|
8
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync, statSync } from "fs";
|
|
9
9
|
import { execSync } from "child_process";
|
|
10
10
|
import { join, resolve } from "path";
|
|
11
|
-
import { homedir } from "os";
|
|
12
11
|
|
|
13
12
|
import { generateBones, renderBuddy, renderFace, RARITY_STARS } from "../../../core/engine.ts";
|
|
14
13
|
import { loadCompanion, saveCompanion, writeStatusState } from "../storage/state.ts";
|
|
15
14
|
import { resolveUserId } from "../storage/identity.ts";
|
|
16
15
|
import { generateFallbackName } from "../../../core/reactions.ts";
|
|
16
|
+
import { getBuddySkillDir, getClaudeConfigDir, getClaudeJsonPath, getClaudeSettingsPath } from "../storage/paths.ts";
|
|
17
17
|
|
|
18
18
|
const CYAN = "\x1b[36m";
|
|
19
19
|
const GREEN = "\x1b[32m";
|
|
@@ -23,9 +23,10 @@ const BOLD = "\x1b[1m";
|
|
|
23
23
|
const DIM = "\x1b[2m";
|
|
24
24
|
const NC = "\x1b[0m";
|
|
25
25
|
|
|
26
|
-
const CLAUDE_DIR =
|
|
27
|
-
const SETTINGS_FILE =
|
|
28
|
-
const BUDDY_DIR =
|
|
26
|
+
const CLAUDE_DIR = getClaudeConfigDir();
|
|
27
|
+
const SETTINGS_FILE = getClaudeSettingsPath();
|
|
28
|
+
const BUDDY_DIR = getBuddySkillDir();
|
|
29
|
+
const CLAUDE_JSON = getClaudeJsonPath();
|
|
29
30
|
const PROJECT_ROOT = resolve(import.meta.dir, "../../..");
|
|
30
31
|
|
|
31
32
|
function banner() {
|
|
@@ -71,21 +72,23 @@ function preflight(): boolean {
|
|
|
71
72
|
}
|
|
72
73
|
}
|
|
73
74
|
|
|
74
|
-
// Check
|
|
75
|
+
// Check Claude config dir exists and is actually a directory
|
|
75
76
|
if (!existsSync(CLAUDE_DIR)) {
|
|
76
|
-
err(
|
|
77
|
+
err(`Claude config dir not found: ${CLAUDE_DIR}`);
|
|
78
|
+
pass = false;
|
|
79
|
+
} else if (!statSync(CLAUDE_DIR).isDirectory()) {
|
|
80
|
+
err(`Claude config path is not a directory: ${CLAUDE_DIR}`);
|
|
77
81
|
pass = false;
|
|
78
82
|
} else {
|
|
79
|
-
ok(
|
|
83
|
+
ok(`Claude config dir found: ${CLAUDE_DIR}`);
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
// Check
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
err("~/.claude.json not found. Start Claude Code once first, then re-run.");
|
|
86
|
+
// Check .claude.json exists in the active Claude config location
|
|
87
|
+
if (!existsSync(CLAUDE_JSON)) {
|
|
88
|
+
err(`Claude config file not found: ${CLAUDE_JSON}`);
|
|
86
89
|
pass = false;
|
|
87
90
|
} else {
|
|
88
|
-
ok(
|
|
91
|
+
ok(`Claude config file found: ${CLAUDE_JSON}`);
|
|
89
92
|
}
|
|
90
93
|
|
|
91
94
|
return pass;
|
|
@@ -145,7 +148,7 @@ function saveSettings(settings: ClaudeSettings) {
|
|
|
145
148
|
|
|
146
149
|
function installMcp() {
|
|
147
150
|
const serverPath = join(PROJECT_ROOT, "adapters", "claude", "server", "index.ts");
|
|
148
|
-
const claudeJsonPath =
|
|
151
|
+
const claudeJsonPath = CLAUDE_JSON;
|
|
149
152
|
|
|
150
153
|
let claudeJson: ClaudeJsonConfig = {};
|
|
151
154
|
try {
|
|
@@ -161,7 +164,7 @@ function installMcp() {
|
|
|
161
164
|
};
|
|
162
165
|
|
|
163
166
|
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
164
|
-
ok(
|
|
167
|
+
ok(`MCP server registered in ${claudeJsonPath}`);
|
|
165
168
|
}
|
|
166
169
|
|
|
167
170
|
// ─── Step 2: Install skill ──────────────────────────────────────────────────
|
|
@@ -170,7 +173,7 @@ function installSkill() {
|
|
|
170
173
|
const srcSkill = join(PROJECT_ROOT, "adapters", "claude", "skills", "buddy", "SKILL.md");
|
|
171
174
|
mkdirSync(BUDDY_DIR, { recursive: true });
|
|
172
175
|
cpSync(srcSkill, join(BUDDY_DIR, "SKILL.md"), { force: true });
|
|
173
|
-
ok(
|
|
176
|
+
ok(`Skill installed: ${join(BUDDY_DIR, "SKILL.md")}`);
|
|
174
177
|
}
|
|
175
178
|
|
|
176
179
|
// ─── Step 3: Configure status line (with animation refresh) ─────────────────
|
|
@@ -14,9 +14,10 @@
|
|
|
14
14
|
import { readFileSync, writeFileSync, existsSync, copyFileSync, chmodSync, mkdirSync } from "fs";
|
|
15
15
|
import { join, dirname, resolve } from "path";
|
|
16
16
|
import { homedir } from "os";
|
|
17
|
+
import { getClaudeSettingsPath } from "../storage/paths.ts";
|
|
17
18
|
|
|
18
19
|
const HOME = homedir();
|
|
19
|
-
const SETTINGS =
|
|
20
|
+
const SETTINGS = getClaudeSettingsPath();
|
|
20
21
|
const BACKUP = join(HOME, ".claude-buddy", "statusline.bak");
|
|
21
22
|
const TEST_SCRIPT = join(HOME, ".claude-buddy", "test-statusline.sh");
|
|
22
23
|
const SOURCE_SCRIPT = resolve(import.meta.dir, "test-statusline.sh");
|
|
@@ -41,7 +42,7 @@ if (action === "install") {
|
|
|
41
42
|
console.log(`\n${BOLD}claude-buddy test status line installer${NC}\n`);
|
|
42
43
|
|
|
43
44
|
if (!existsSync(SETTINGS)) {
|
|
44
|
-
err(
|
|
45
|
+
err(`Claude settings not found: ${SETTINGS}`);
|
|
45
46
|
process.exit(1);
|
|
46
47
|
}
|
|
47
48
|
if (!existsSync(SOURCE_SCRIPT)) {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync, rmSync, readdirSync } from "fs";
|
|
6
6
|
import { join } from "path";
|
|
7
7
|
import { homedir } from "os";
|
|
8
|
+
import { getBuddySkillDir, getClaudeConfigDir, getClaudeJsonPath, getClaudeSettingsPath } from "../storage/paths.ts";
|
|
8
9
|
|
|
9
10
|
interface HookCommand {
|
|
10
11
|
type: "command";
|
|
@@ -33,9 +34,10 @@ const NC = "\x1b[0m";
|
|
|
33
34
|
function ok(msg: string) { console.log(`${GREEN}✓${NC} ${msg}`); }
|
|
34
35
|
function warn(msg: string) { console.log(`${YELLOW}⚠${NC} ${msg}`); }
|
|
35
36
|
|
|
36
|
-
const CLAUDE_DIR =
|
|
37
|
-
const SETTINGS_FILE =
|
|
38
|
-
const SKILL_DIR =
|
|
37
|
+
const CLAUDE_DIR = getClaudeConfigDir();
|
|
38
|
+
const SETTINGS_FILE = getClaudeSettingsPath();
|
|
39
|
+
const SKILL_DIR = getBuddySkillDir();
|
|
40
|
+
const CLAUDE_JSON = getClaudeJsonPath();
|
|
39
41
|
const STATE_DIR = join(homedir(), ".claude-buddy");
|
|
40
42
|
|
|
41
43
|
console.log("\nclaude-buddy uninstall\n");
|
|
@@ -69,16 +71,15 @@ try {
|
|
|
69
71
|
|
|
70
72
|
// Remove MCP server from ~/.claude.json
|
|
71
73
|
try {
|
|
72
|
-
const
|
|
73
|
-
const claudeJson = JSON.parse(readFileSync(claudeJsonPath, "utf8"));
|
|
74
|
+
const claudeJson = JSON.parse(readFileSync(CLAUDE_JSON, "utf8"));
|
|
74
75
|
if (claudeJson.mcpServers?.["claude-buddy"]) {
|
|
75
76
|
delete claudeJson.mcpServers["claude-buddy"];
|
|
76
77
|
if (Object.keys(claudeJson.mcpServers).length === 0) delete claudeJson.mcpServers;
|
|
77
|
-
writeFileSync(
|
|
78
|
-
ok(
|
|
78
|
+
writeFileSync(CLAUDE_JSON, JSON.stringify(claudeJson, null, 2));
|
|
79
|
+
ok(`MCP server removed from ${CLAUDE_JSON}`);
|
|
79
80
|
}
|
|
80
81
|
} catch {
|
|
81
|
-
warn(
|
|
82
|
+
warn(`Could not update ${CLAUDE_JSON}`);
|
|
82
83
|
}
|
|
83
84
|
|
|
84
85
|
// Remove hooks and statusline from settings.json
|
|
@@ -5,14 +5,14 @@
|
|
|
5
5
|
},
|
|
6
6
|
"metadata": {
|
|
7
7
|
"description": "Permanent coding companion for Claude Code",
|
|
8
|
-
"version": "0.
|
|
8
|
+
"version": "0.4.0-alpha.5"
|
|
9
9
|
},
|
|
10
10
|
"plugins": [
|
|
11
11
|
{
|
|
12
12
|
"name": "claude-buddy",
|
|
13
13
|
"source": "./",
|
|
14
14
|
"description": "Permanent coding companion for Claude Code \u2014 survives any update. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
|
|
15
|
-
"version": "0.
|
|
15
|
+
"version": "0.4.0-alpha.5",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "1270011"
|
|
18
18
|
},
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-buddy",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0-alpha.5",
|
|
4
4
|
"description": "Permanent coding companion for Claude Code \u2014 survives any update. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "1270011"
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import {
|
|
3
|
-
import { homedir } from "os";
|
|
2
|
+
import { getClaudeJsonPath } from "./paths.ts";
|
|
4
3
|
|
|
5
4
|
export function resolveUserId(): string {
|
|
6
5
|
try {
|
|
7
6
|
const claudeJson = JSON.parse(
|
|
8
|
-
readFileSync(
|
|
7
|
+
readFileSync(getClaudeJsonPath(), "utf8"),
|
|
9
8
|
);
|
|
10
9
|
return claudeJson.oauthAccount?.accountUuid ?? claudeJson.userID ?? "anon";
|
|
11
10
|
} catch {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
import { join, resolve } from "path";
|
|
3
|
+
|
|
4
|
+
function claudeConfigDirEnv(): string | null {
|
|
5
|
+
const value = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
6
|
+
return value ? resolve(value) : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getClaudeConfigDir(): string {
|
|
10
|
+
return claudeConfigDirEnv() ?? join(homedir(), ".claude");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getClaudeJsonPath(): string {
|
|
14
|
+
const configDir = claudeConfigDirEnv();
|
|
15
|
+
return configDir ? join(configDir, ".claude.json") : join(homedir(), ".claude.json");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getClaudeSettingsPath(): string {
|
|
19
|
+
return join(getClaudeConfigDir(), "settings.json");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getBuddySkillDir(): string {
|
|
23
|
+
return join(getClaudeConfigDir(), "skills", "buddy");
|
|
24
|
+
}
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync, renameSync } from "fs";
|
|
2
|
-
import {
|
|
3
|
-
import { homedir } from "os";
|
|
2
|
+
import { getClaudeSettingsPath } from "./paths.ts";
|
|
4
3
|
|
|
5
|
-
export const CLAUDE_SETTINGS_PATH =
|
|
4
|
+
export const CLAUDE_SETTINGS_PATH = getClaudeSettingsPath();
|
|
6
5
|
|
|
7
6
|
export function setBuddyStatusLine(
|
|
8
7
|
statusScript: string,
|
package/adapters/pi/commands.ts
CHANGED
|
@@ -81,7 +81,7 @@ export function registerBuddyCommands(pi: ExtensionAPI, deps: RegisterBuddyComma
|
|
|
81
81
|
case "on": {
|
|
82
82
|
deps.service.incrementCommandsRun();
|
|
83
83
|
deps.storage.setMuted(false);
|
|
84
|
-
const result = deps.service.
|
|
84
|
+
const result = deps.service.recordComment("*stretches* I'm back!");
|
|
85
85
|
deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
|
|
86
86
|
ctx.ui.notify(`${result.companion.name} is back.`, "info");
|
|
87
87
|
deps.ui.notifyAchievements(ctx, result.achievements);
|
package/adapters/pi/events.ts
CHANGED
|
@@ -8,7 +8,12 @@ import type {
|
|
|
8
8
|
TurnEndEvent,
|
|
9
9
|
} from "@mariozechner/pi-coding-agent";
|
|
10
10
|
import { isBashToolResult } from "@mariozechner/pi-coding-agent";
|
|
11
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
|
12
|
+
import type { AssistantMessage, TextContent } from "@mariozechner/pi-ai";
|
|
11
13
|
import { BuddyCommandService } from "../../core/command-service.ts";
|
|
14
|
+
import type { Achievement } from "../../core/achievements.ts";
|
|
15
|
+
import type { Companion } from "../../core/model.ts";
|
|
16
|
+
import { getNameReaction, getSuccessReaction } from "../../core/reactions.ts";
|
|
12
17
|
import { PiBuddyStorage } from "./storage.ts";
|
|
13
18
|
import { PiBuddyUI } from "./ui.ts";
|
|
14
19
|
|
|
@@ -39,11 +44,8 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
|
|
|
39
44
|
return { action: "continue" };
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const result = deps.service.recordNameMention();
|
|
47
|
+
const reaction = getNameReaction(companion.bones.species);
|
|
48
|
+
const result = deps.service.recordComment(reaction, "turn");
|
|
47
49
|
deps.ui.refresh(ctx, result.companion, result.state, result.achievements);
|
|
48
50
|
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
49
51
|
return { action: "continue" };
|
|
@@ -57,8 +59,10 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
|
|
|
57
59
|
| ReturnType<BuddyCommandService["recordToolError"]>
|
|
58
60
|
| ReturnType<BuddyCommandService["recordTestFailure"]>
|
|
59
61
|
| ReturnType<BuddyCommandService["recordLargeDiff"]>
|
|
62
|
+
| ReturnType<BuddyCommandService["recordComment"]>
|
|
60
63
|
| undefined;
|
|
61
64
|
|
|
65
|
+
const companion = deps.service.ensureCompanion().companion;
|
|
62
66
|
if (event.isError) {
|
|
63
67
|
result = deps.service.recordToolError(undefined, firstLineNumber(text));
|
|
64
68
|
} else if (looksLikeTestFailure(text)) {
|
|
@@ -67,6 +71,8 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
|
|
|
67
71
|
const diffLines = extractLargeDiffLines(text);
|
|
68
72
|
if (diffLines >= 80) {
|
|
69
73
|
result = deps.service.recordLargeDiff(diffLines);
|
|
74
|
+
} else if (looksLikeSuccess(text)) {
|
|
75
|
+
result = deps.service.recordComment(getSuccessReaction(companion.bones.species), "turn");
|
|
70
76
|
}
|
|
71
77
|
}
|
|
72
78
|
|
|
@@ -75,25 +81,28 @@ export function registerBuddyEvents(pi: ExtensionAPI, deps: RegisterBuddyEventsD
|
|
|
75
81
|
deps.ui.notifyAchievements(ctx, result.achievements);
|
|
76
82
|
});
|
|
77
83
|
|
|
78
|
-
pi.on("turn_end", async (
|
|
79
|
-
const
|
|
84
|
+
pi.on("turn_end", async (event: TurnEndEvent, ctx: ExtensionContext) => {
|
|
85
|
+
const progress = deps.service.recordTurnOnly();
|
|
80
86
|
if (deps.storage.isMuted()) {
|
|
81
|
-
deps.ui.refresh(ctx,
|
|
87
|
+
deps.ui.refresh(ctx, progress.companion, null, progress.achievements);
|
|
82
88
|
return;
|
|
83
89
|
}
|
|
84
90
|
|
|
85
91
|
if (!shouldEmitPassiveReaction(deps.storage)) {
|
|
86
|
-
deps.ui.refresh(ctx,
|
|
92
|
+
deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
|
|
87
93
|
return;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
deps.ui.
|
|
96
|
+
const comment = deriveTurnComment(progress.companion, event.message);
|
|
97
|
+
if (!comment) {
|
|
98
|
+
deps.ui.refresh(ctx, progress.companion, deps.storage.loadLatest(), progress.achievements);
|
|
93
99
|
return;
|
|
94
100
|
}
|
|
95
101
|
|
|
96
|
-
|
|
102
|
+
const reaction = deps.service.recordComment(comment, "turn");
|
|
103
|
+
const achievements = mergeAchievements(progress.achievements, reaction.achievements);
|
|
104
|
+
deps.ui.refresh(ctx, reaction.companion, reaction.state, achievements);
|
|
105
|
+
deps.ui.notifyAchievements(ctx, achievements);
|
|
97
106
|
});
|
|
98
107
|
}
|
|
99
108
|
|
|
@@ -122,6 +131,10 @@ function looksLikeTestFailure(text: string): boolean {
|
|
|
122
131
|
return /(FAIL|failed|failing|test(s)? failed|not ok)/i.test(text);
|
|
123
132
|
}
|
|
124
133
|
|
|
134
|
+
function looksLikeSuccess(text: string): boolean {
|
|
135
|
+
return /\b(all )?[0-9]+ tests? (passed|ok)\b|✓|✔|PASS(ED)?|\bDone\b|\bSuccess\b|exit code 0|Build succeeded/i.test(text);
|
|
136
|
+
}
|
|
137
|
+
|
|
125
138
|
function extractFailureCount(text: string): number | undefined {
|
|
126
139
|
const match = text.match(/(\d+)\s+(tests? )?(failed|failing)/i);
|
|
127
140
|
return match ? Number(match[1]) : undefined;
|
|
@@ -148,3 +161,100 @@ function firstLineNumber(text: string): number | undefined {
|
|
|
148
161
|
function escapeRegExp(value: string): string {
|
|
149
162
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
150
163
|
}
|
|
164
|
+
|
|
165
|
+
function mergeAchievements(first: Achievement[], second: Achievement[]): Achievement[] {
|
|
166
|
+
const merged = new Map<string, Achievement>();
|
|
167
|
+
for (const achievement of [...first, ...second]) {
|
|
168
|
+
merged.set(achievement.id, achievement);
|
|
169
|
+
}
|
|
170
|
+
return [...merged.values()];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
|
|
174
|
+
return message.role === "assistant" && Array.isArray(message.content);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function getAssistantText(message: AssistantMessage): string {
|
|
178
|
+
return message.content
|
|
179
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
180
|
+
.map((block) => block.text)
|
|
181
|
+
.join("\n");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function deriveTurnComment(companion: Companion, message: AgentMessage): string | null {
|
|
185
|
+
if (!isAssistantMessage(message)) return null;
|
|
186
|
+
|
|
187
|
+
const text = sanitizeAssistantText(getAssistantText(message));
|
|
188
|
+
if (!text) return null;
|
|
189
|
+
|
|
190
|
+
const file = firstMatch(text, /`([^`]+\.[a-z0-9]+)`/i)
|
|
191
|
+
?? firstMatch(text, /\b([A-Za-z0-9_./-]+\.(?:ts|tsx|js|jsx|json|md|sh|py|rs|go|java|rb|css|html|ya?ml))\b/i);
|
|
192
|
+
|
|
193
|
+
if (file) {
|
|
194
|
+
return fitComment(`*takes note* ${file} got the attention this turn.`, 150);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (/\b(regex|unicode)\b/i.test(text)) {
|
|
198
|
+
return fitComment("*head tilts* that regex still wants a second look.", 150);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (/\b(test|tests|assert|spec)\b/i.test(text)) {
|
|
202
|
+
return fitComment("*nods slowly* good. keep the tests honest.", 150);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (/\b(error|bug|fix|failure|failing|exception)\b/i.test(text)) {
|
|
206
|
+
return fitComment("*watches closely* one fix always tries to drag a second one behind it.", 150);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const topic = firstMeaningfulSentence(text);
|
|
210
|
+
if (!topic) return null;
|
|
211
|
+
|
|
212
|
+
const speciesLead = companion.bones.species === "owl"
|
|
213
|
+
? "*blinks slowly*"
|
|
214
|
+
: companion.bones.species === "snail"
|
|
215
|
+
? "*slow nod*"
|
|
216
|
+
: "*takes note*";
|
|
217
|
+
|
|
218
|
+
return fitComment(`${speciesLead} ${topic}`, 150);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function sanitizeAssistantText(text: string): string {
|
|
222
|
+
return text
|
|
223
|
+
.replace(/<!--([\s\S]*?)-->/g, " ")
|
|
224
|
+
.replace(/```[\s\S]*?```/g, " ")
|
|
225
|
+
.replace(/`([^`]+)`/g, "$1")
|
|
226
|
+
.replace(/^#{1,6}\s+/gm, "")
|
|
227
|
+
.replace(/^\s*[-*+]\s+/gm, "")
|
|
228
|
+
.replace(/\[(.*?)\]\((.*?)\)/g, "$1")
|
|
229
|
+
.replace(/\s+/g, " ")
|
|
230
|
+
.trim();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function firstMatch(text: string, pattern: RegExp): string | null {
|
|
234
|
+
const match = text.match(pattern);
|
|
235
|
+
return match?.[1]?.trim() || null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function firstMeaningfulSentence(text: string): string | null {
|
|
239
|
+
const sentences = text
|
|
240
|
+
.split(/(?<=[.!?])\s+/)
|
|
241
|
+
.map((sentence) => sentence.trim())
|
|
242
|
+
.filter(Boolean);
|
|
243
|
+
|
|
244
|
+
for (const sentence of sentences) {
|
|
245
|
+
const cleaned = sentence
|
|
246
|
+
.replace(/^here'?s what I (?:did|changed)[:\-]?\s*/i, "")
|
|
247
|
+
.replace(/^I\s+/i, "")
|
|
248
|
+
.replace(/^we\s+/i, "")
|
|
249
|
+
.trim();
|
|
250
|
+
if (cleaned.length < 18) continue;
|
|
251
|
+
return cleaned;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return text.length >= 18 ? text : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function fitComment(text: string, maxLength: number): string {
|
|
258
|
+
if (text.length <= maxLength) return text;
|
|
259
|
+
return `${text.slice(0, maxLength - 1).trimEnd()}…`;
|
|
260
|
+
}
|
package/core/command-service.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface ReactionResult {
|
|
|
40
40
|
achievements: Achievement[];
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export interface ProgressResult {
|
|
44
|
+
companion: Companion;
|
|
45
|
+
slot: string;
|
|
46
|
+
achievements: Achievement[];
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
export interface SaveBuddyResult {
|
|
44
50
|
companion: Companion;
|
|
45
51
|
slot: string;
|
|
@@ -209,6 +215,24 @@ export class BuddyCommandService {
|
|
|
209
215
|
return this.saveReaction("turn", companion, slot, scope);
|
|
210
216
|
}
|
|
211
217
|
|
|
218
|
+
recordTurnOnly(): ProgressResult {
|
|
219
|
+
const { companion, slot } = this.ensureCompanion();
|
|
220
|
+
this.deps.events.increment("turns", 1);
|
|
221
|
+
const achievements = this.unlockAchievements(slot);
|
|
222
|
+
return { companion, slot, achievements };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
recordComment(
|
|
226
|
+
comment: string,
|
|
227
|
+
reason: "turn" | "error" | "test-fail" | "large-diff" = "turn",
|
|
228
|
+
scope?: string,
|
|
229
|
+
): ReactionResult {
|
|
230
|
+
const trimmed = comment.trim();
|
|
231
|
+
if (!trimmed) throw new Error("Buddy comment cannot be empty.");
|
|
232
|
+
const { companion, slot } = this.ensureCompanion();
|
|
233
|
+
return this.saveCustomReaction(trimmed, reason, companion, slot, scope);
|
|
234
|
+
}
|
|
235
|
+
|
|
212
236
|
incrementCommandsRun(): Achievement[] {
|
|
213
237
|
this.deps.events.increment("commands_run", 1, this.getActiveSlotOrUndefined());
|
|
214
238
|
return this.unlockAchievements(this.getActiveSlotOrUndefined());
|
|
@@ -292,6 +316,16 @@ export class BuddyCommandService {
|
|
|
292
316
|
companion.bones.rarity,
|
|
293
317
|
context,
|
|
294
318
|
);
|
|
319
|
+
return this.saveCustomReaction(reaction, reason, companion, slot, scope);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private saveCustomReaction(
|
|
323
|
+
reaction: string,
|
|
324
|
+
reason: "pet" | "turn" | "error" | "test-fail" | "large-diff",
|
|
325
|
+
companion: Companion,
|
|
326
|
+
slot: string,
|
|
327
|
+
scope?: string,
|
|
328
|
+
): ReactionResult {
|
|
295
329
|
const state: ReactionState = {
|
|
296
330
|
reaction,
|
|
297
331
|
reason,
|
package/core/reactions.ts
CHANGED
|
@@ -6,6 +6,51 @@ import type { Species, Rarity, StatName } from "./engine.ts";
|
|
|
6
6
|
|
|
7
7
|
type ReactionReason = "hatch" | "pet" | "error" | "test-fail" | "large-diff" | "turn" | "idle";
|
|
8
8
|
|
|
9
|
+
const NAME_REACTIONS: Partial<Record<Species, string[]>> = {
|
|
10
|
+
dragon: ["*one eye opens slowly*", "...you called?", "*smoke curls from nostril* yes.", "*regards you from above*"],
|
|
11
|
+
owl: ["*swivels head 180°*", "*blinks once, deliberately*", "hm.", "*adjusts perch*"],
|
|
12
|
+
cat: ["*ear flicks*", "...what.", "*ignores you, but heard*", "*opens one eye*"],
|
|
13
|
+
duck: ["*quack*", "*looks up mid-waddle*", "*attentive duck noises*"],
|
|
14
|
+
ghost: ["*materialises*", "...boo?", "*phases closer*"],
|
|
15
|
+
robot: ["NAME DETECTED.", "*whirrs attentively*", "STANDING BY."],
|
|
16
|
+
capybara: ["*barely moves*", "*blinks slowly*", "...yes, friend."],
|
|
17
|
+
axolotl: ["*gill flutter*", "*smiles gently*", "oh! hello."],
|
|
18
|
+
blob: ["*jiggles*", "*oozes toward you*", "*wobbles excitedly*"],
|
|
19
|
+
turtle: ["*slowly extends neck*", "...you called?", "*ancient eyes open*", "*shell creaks thoughtfully*", "*blinks once, patiently*"],
|
|
20
|
+
goose: ["HONK.", "*necks aggressively*", "*wing flap*", "*honks in recognition*"],
|
|
21
|
+
octopus: ["*eight eyes open*", "*curls an arm toward you*", "*changes color curiously*", "...yes, friend?"],
|
|
22
|
+
penguin: ["*adjusts tie*", "*dignified waddle*", "*bows slightly*", "...yes, quite?"],
|
|
23
|
+
snail: ["*slow head extension*", "...mmm?", "*trails slowly toward you*", "*antenna twitches*"],
|
|
24
|
+
cactus: ["*stands silent*", "...hm.", "*spine twitches*", "*slowly rotates*"],
|
|
25
|
+
rabbit: ["*ears perk up*", "*nose twitches*", "yes?", "*hops closer*"],
|
|
26
|
+
mushroom: ["*releases a tiny spore*", "*cap tilts*", "*stands mysterious*", "...yes?"],
|
|
27
|
+
chonk: ["*barely opens one eye*", "...mrrp?", "*yawns heavily*", "*rolls over toward you*"],
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SUCCESS_REACTIONS: Partial<Record<Species, string[]>> = {
|
|
31
|
+
dragon: ["*nods, barely*", "...acceptable.", "*gold eyes gleam*", "as expected."],
|
|
32
|
+
owl: ["*satisfied hoot*", "knowledge confirmed.", "*nods sagely*", "as the tests have spoken."],
|
|
33
|
+
cat: ["*was never worried*", "*yawns*", "I knew you'd figure it out. eventually.", "*already asleep*"],
|
|
34
|
+
duck: ["*celebratory quacking*", "*waddles in circles*", "quack!", "*happy duck noises*"],
|
|
35
|
+
robot: ["OBJECTIVE: COMPLETE.", "*satisfying beep*", "NOMINAL.", "WITHIN ACCEPTABLE PARAMETERS."],
|
|
36
|
+
capybara: ["*maximum chill maintained*", "*nods once*", "good vibes.", "see? no panic needed."],
|
|
37
|
+
ghost: ["*drifts in quiet approval*", "not bad for the living.", "*soft spectral nod*", "the haunting may continue peacefully."],
|
|
38
|
+
axolotl: ["*happy gill flutter*", "*beams*", "you did it!", "*blushes pink*"],
|
|
39
|
+
blob: ["*jiggles happily*", "*gleams*", "yay!", "*bounces*"],
|
|
40
|
+
turtle: ["*satisfied shell settle*", "as the ancients foretold.", "*slow approving nod*", "good. very good."],
|
|
41
|
+
goose: ["*victorious honk*", "HONK OF APPROVAL.", "*struts triumphantly*", "*wing spread of victory*"],
|
|
42
|
+
octopus: ["*turns gentle blue*", "*arms applaud in sync*", "excellent, from all angles.", "*satisfied bubble*"],
|
|
43
|
+
penguin: ["*polite applause*", "quite good, quite good.", "*nods approvingly*", "splendid work, really."],
|
|
44
|
+
snail: ["*slow satisfied nod*", "good things take time.", "*leaves victory slime*", "see? no rush was needed."],
|
|
45
|
+
cactus: ["*blooms briefly*", "survival confirmed.", "*flowers in victory*", "*quiet bloom*"],
|
|
46
|
+
rabbit: ["*excited binky*", "*zoomies of joy*", "yay yay yay!", "*thumps in celebration*"],
|
|
47
|
+
mushroom: ["*spores of celebration*", "the mycelium approves.", "*cap brightens*", "spore of pride."],
|
|
48
|
+
chonk: ["*happy purr*", "*satisfied chonk noises*", "acceptable.", "*sleeps even harder*"] ,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const DEFAULT_NAME_REACTIONS = ["*perks up*", "...yes?", "*looks your way*"];
|
|
52
|
+
const DEFAULT_SUCCESS_REACTIONS = ["*nods*", "nice.", "*quiet approval*", "clean."];
|
|
53
|
+
|
|
9
54
|
interface ReactionPool {
|
|
10
55
|
[key: string]: string[];
|
|
11
56
|
}
|
|
@@ -139,6 +184,16 @@ export function getReaction(
|
|
|
139
184
|
return reaction;
|
|
140
185
|
}
|
|
141
186
|
|
|
187
|
+
export function getNameReaction(species: Species): string {
|
|
188
|
+
const pool = NAME_REACTIONS[species] ?? DEFAULT_NAME_REACTIONS;
|
|
189
|
+
return pool[Math.floor(Math.random() * pool.length)];
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getSuccessReaction(species: Species): string {
|
|
193
|
+
const pool = SUCCESS_REACTIONS[species] ?? DEFAULT_SUCCESS_REACTIONS;
|
|
194
|
+
return pool[Math.floor(Math.random() * pool.length)];
|
|
195
|
+
}
|
|
196
|
+
|
|
142
197
|
// ─── Personality generation (fallback names when API unavailable) ────────────
|
|
143
198
|
|
|
144
199
|
const FALLBACK_NAMES = [
|