@lebronj/pi-suite 0.1.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.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/extensions/pet.ts +1033 -0
  4. package/extensions/prompt-url-widget.ts +158 -0
  5. package/extensions/redraws.ts +24 -0
  6. package/extensions/snake.ts +343 -0
  7. package/extensions/tps.ts +47 -0
  8. package/package.json +69 -0
  9. package/prompts/cl.md +54 -0
  10. package/prompts/is.md +25 -0
  11. package/prompts/pr.md +37 -0
  12. package/prompts/wr.md +35 -0
  13. package/scripts/bootstrap.sh +95 -0
  14. package/skills/add-llm-provider.md +57 -0
  15. package/skills/image-to-editable-ppt-slide/SKILL.md +113 -0
  16. package/skills/image-to-editable-ppt-slide/scripts/generate_spec_template.py +91 -0
  17. package/skills/image-to-editable-ppt-slide/scripts/pptx_rebuilder.py +181 -0
  18. package/skills/leetcode-array/SKILL.md +40 -0
  19. package/skills/leetcode-array/problems/best_time_to_buy_and_sell_stock.py +19 -0
  20. package/skills/leetcode-array/problems/product_of_array_except_self.py +22 -0
  21. package/skills/leetcode-array/problems/two_sum.py +19 -0
  22. package/skills/pi-skill/SKILL.md +154 -0
  23. package/skills/weather.md +49 -0
  24. package/vendor/pi-memory/LICENSE +21 -0
  25. package/vendor/pi-memory/README.md +223 -0
  26. package/vendor/pi-memory/index.ts +2367 -0
  27. package/vendor/pi-memory/package.json +68 -0
  28. package/vendor/pi-memory/scripts/postinstall.cjs +44 -0
  29. package/vendor/pi-memory/src/cli.ts +79 -0
  30. package/vendor/pi-memory/src/curator-core/audit.ts +45 -0
  31. package/vendor/pi-memory/src/curator-core/curate.ts +90 -0
  32. package/vendor/pi-memory/src/curator-core/metadata.ts +55 -0
  33. package/vendor/pi-memory/src/curator-core/patch.ts +24 -0
  34. package/vendor/pi-memory/src/curator-core/policy.ts +77 -0
  35. package/vendor/pi-memory/src/curator-store/file-store.ts +51 -0
  36. package/vendor/pi-memory/src/curator-store/types.ts +21 -0
  37. package/vendor/pi-memory/src/index.ts +35 -0
  38. package/vendor/pi-memory/src/learning/candidates.ts +205 -0
  39. package/vendor/pi-memory/src/learning/memory.ts +144 -0
  40. package/vendor/pi-memory/src/learning/skills.ts +200 -0
  41. package/vendor/pi-memory/src/service-controller.ts +248 -0
  42. package/vendor/pi-memory/test/curate.test.ts +68 -0
  43. package/vendor/pi-memory/test/learning-candidates.test.ts +107 -0
  44. package/vendor/pi-memory/test/memory-promotions.test.ts +44 -0
  45. package/vendor/pi-memory/test/metadata.test.ts +17 -0
  46. package/vendor/pi-memory/test/skill-drafts.test.ts +57 -0
  47. package/vendor/pi-memory/test/transition-handoff.test.ts +86 -0
@@ -0,0 +1,1033 @@
1
+ /**
2
+ * Terminal pet extension - shows a small animated companion near the editor.
3
+ *
4
+ * Usage:
5
+ * pi --extension examples/extensions/pet.ts
6
+ *
7
+ * Commands:
8
+ * /pet Show pet profile
9
+ * /pet on|off Show or hide the pet
10
+ * /pet cat|dog|fox|bot Switch species
11
+ * /pet name <name> Rename the pet
12
+ * /pet mood Show mood, stats, and progress
13
+ * /pet checkin Claim the daily check-in reward
14
+ * /pet feed Feed the pet
15
+ * /pet bag Show inventory items
16
+ * /pet equip <item> Equip a cosmetic item
17
+ * /pet unequip Remove the equipped item
18
+ * /pet position widget|overlay Move between editor widget and floating overlay
19
+ * /pet reset Reset pet profile
20
+ * /pet ask <question> Ask about current context without saving the answer
21
+ * /pet <message> Talk to the pet locally
22
+ */
23
+
24
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
25
+ import { complete, type Message } from "@earendil-works/pi-ai";
26
+ import {
27
+ BorderedLoader,
28
+ convertToLlm,
29
+ type ExtensionAPI,
30
+ type ExtensionCommandContext,
31
+ type ExtensionContext,
32
+ type SessionEntry,
33
+ serializeConversation,
34
+ type Theme,
35
+ } from "@earendil-works/pi-coding-agent";
36
+ import type { Component, OverlayHandle, TUI } from "@earendil-works/pi-tui";
37
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
38
+
39
+ type PetMood = "idle" | "thinking" | "tool" | "chat" | "celebrate" | "concerned";
40
+ type PetKind = "cat" | "dog" | "fox" | "bot";
41
+ type PetRarity = "common" | "rare" | "epic" | "legendary";
42
+ type PetPersonality = "calm" | "curious" | "snarky" | "loyal" | "sleepy";
43
+ type PetPosition = "widget" | "overlay";
44
+ type PetItemRarity = "common" | "rare" | "epic" | "legendary";
45
+ type PetItemTrigger = "tool" | "memory" | "checkin";
46
+
47
+ interface PetItem {
48
+ id: string;
49
+ name: string;
50
+ rarity: PetItemRarity;
51
+ glyph: string;
52
+ color: "accent" | "success" | "warning" | "error" | "muted";
53
+ trigger: PetItemTrigger;
54
+ }
55
+
56
+ interface PetInventoryItem {
57
+ itemId: string;
58
+ count: number;
59
+ }
60
+
61
+ interface PetStats {
62
+ focus: number;
63
+ energy: number;
64
+ curiosity: number;
65
+ sass: number;
66
+ loyalty: number;
67
+ }
68
+
69
+ interface PetProfile {
70
+ name: string;
71
+ species: PetKind;
72
+ rarity: PetRarity;
73
+ personality: PetPersonality;
74
+ stats: PetStats;
75
+ xp: number;
76
+ level: number;
77
+ interactions: number;
78
+ toolsCompleted: number;
79
+ position: PetPosition;
80
+ enabled: boolean;
81
+ lastCheckInDay?: string;
82
+ feedCount: number;
83
+ inventory: PetInventoryItem[];
84
+ equippedItemId?: string;
85
+ }
86
+
87
+ const PET_PROFILE_TYPE = "pet-profile";
88
+ const BASE_TICK_MS = 500;
89
+ const CHAT_MOOD_MS = 5500;
90
+ const SLEEP_AFTER_MS = 25_000;
91
+ const PET_KINDS: PetKind[] = ["cat", "dog", "fox", "bot"];
92
+ const PERSONALITIES: PetPersonality[] = ["calm", "curious", "snarky", "loyal", "sleepy"];
93
+ const RARITIES: PetRarity[] = ["common", "rare", "epic", "legendary"];
94
+ const PET_ITEMS: PetItem[] = [
95
+ { id: "tin-bell", name: "Tin Bell", rarity: "common", glyph: "o", color: "muted", trigger: "tool" },
96
+ { id: "green-scarf", name: "Green Scarf", rarity: "common", glyph: "~", color: "success", trigger: "checkin" },
97
+ { id: "amber-token", name: "Amber Token", rarity: "rare", glyph: "*", color: "warning", trigger: "tool" },
98
+ { id: "memory-lens", name: "Memory Lens", rarity: "rare", glyph: "@", color: "accent", trigger: "memory" },
99
+ { id: "violet-badge", name: "Violet Badge", rarity: "epic", glyph: "#", color: "error", trigger: "memory" },
100
+ { id: "star-crown", name: "Star Crown", rarity: "legendary", glyph: "^", color: "warning", trigger: "memory" },
101
+ ];
102
+ const DAILY_CHECKIN_XP = 2;
103
+ const FEED_XP = 1;
104
+ const TOOL_DROP_CHANCE = 0.015;
105
+ const MEMORY_DROP_CHANCE = 0.05;
106
+ const CHECKIN_DROP_CHANCE = 0.08;
107
+
108
+ const PET_ASK_PROMPT = `You are a tiny terminal pet companion inside pi.
109
+ Answer the user's question using the provided conversation context.
110
+ Keep the answer concise, friendly, and technically useful.
111
+ Do not claim you changed files or interacted with the session.
112
+ Your answer is temporary UI output and must not ask the main agent to continue.`;
113
+
114
+ const PET_ART: Record<PetKind, Record<PetMood | "blink" | "sleep", string[][]>> = {
115
+ cat: {
116
+ idle: [[" /\\_/\\", " ( o.o )", " > ^ <"]],
117
+ blink: [[" /\\_/\\", " ( -.- )", " > ^ <"]],
118
+ sleep: [
119
+ [" /\\_/\\", " ( -.- ) z", " > ^ <"],
120
+ [" /\\_/\\", " ( -.- ) zz", " > ^ <"],
121
+ [" /\\_/\\", " ( -.- ) zzz", " > ^ <"],
122
+ ],
123
+ thinking: [
124
+ [" /\\_/\\", " ( o.o ) hmm", " > ? <"],
125
+ [" /\\_/\\", " ( o.o ) hmm.", " > ? <"],
126
+ [" /\\_/\\", " ( o.o ) hmm..", " > ? <"],
127
+ ],
128
+ tool: [
129
+ [" /\\_/\\", " ( >.< ) tap", " ./|___|\\."],
130
+ [" /\\_/\\", " ( o.o ) hunt", " ./|___|\\."],
131
+ ],
132
+ chat: [
133
+ [" /\\_/\\", " ( ^.^ ) meow", " > ^ <"],
134
+ [" /\\_/\\", " ( o.o ) listen", " > ^ <"],
135
+ ],
136
+ celebrate: [[" /\\_/\\", " ( ^.^ ) done", " \\> ^ </"]],
137
+ concerned: [[" /\\_/\\", " ( o.o ) uh oh", " > ! <"]],
138
+ },
139
+ dog: {
140
+ idle: [[" /-----\\", " ( o o )", " \\_^_/"]],
141
+ blink: [[" /-----\\", " ( - - )", " \\_^_/"]],
142
+ sleep: [
143
+ [" /-----\\", " ( - - ) z", " \\_^_/"],
144
+ [" /-----\\", " ( - - ) zz", " \\_^_/"],
145
+ [" /-----\\", " ( - - ) zzz", " \\_^_/"],
146
+ ],
147
+ thinking: [
148
+ [" /-----\\", " ( o o ) sniff", " \\_?_/"],
149
+ [" /-----\\", " ( o o ) sniff.", " \\_?_/"],
150
+ [" /-----\\", " ( o o ) sniff..", " \\_?_/"],
151
+ ],
152
+ tool: [
153
+ [" /-----\\", " ( > < ) fetch", " /|___|\\"],
154
+ [" /-----\\", " ( o o ) dig", " /|___|\\"],
155
+ ],
156
+ chat: [
157
+ [" /-----\\", " ( ^ ^ ) woof", " \\_^_/"],
158
+ [" /-----\\", " ( o o ) listen", " \\_^_/"],
159
+ ],
160
+ celebrate: [[" /-----\\", " ( ^ ^ ) good", " \\|___|/"]],
161
+ concerned: [[" /-----\\", " ( o o ) guard", " \\_!_/"]],
162
+ },
163
+ fox: {
164
+ idle: [[" /\\ /\\", " ( o.o )", " \\ v /"]],
165
+ blink: [[" /\\ /\\", " ( -.- )", " \\ v /"]],
166
+ sleep: [
167
+ [" /\\ /\\", " ( -.- ) z", " \\ v /"],
168
+ [" /\\ /\\", " ( -.- ) zz", " \\ v /"],
169
+ [" /\\ /\\", " ( -.- ) zzz", " \\ v /"],
170
+ ],
171
+ thinking: [
172
+ [" /\\ /\\", " ( o.o ) plot", " \\ ? /"],
173
+ [" /\\ /\\", " ( o.o ) plot.", " \\ ? /"],
174
+ [" /\\ /\\", " ( o.o ) plot..", " \\ ? /"],
175
+ ],
176
+ tool: [
177
+ [" /\\ /\\", " ( >.< ) scout", " /|___|\\"],
178
+ [" /\\ /\\", " ( o.o ) pounce", " /|___|\\"],
179
+ ],
180
+ chat: [
181
+ [" /\\ /\\", " ( ^.^ ) yip", " \\ v /"],
182
+ [" /\\ /\\", " ( o.o ) listen", " \\ v /"],
183
+ ],
184
+ celebrate: [[" /\\ /\\", " ( ^.^ ) clever", " \\ v /"]],
185
+ concerned: [[" /\\ /\\", " ( o.o ) wait", " \\ ! /"]],
186
+ },
187
+ bot: {
188
+ idle: [[" .----.", " [ o_o ]", " /|___|\\"]],
189
+ blink: [[" .----.", " [ -_- ]", " /|___|\\"]],
190
+ sleep: [
191
+ [" .----.", " [ -_- ] idle", " /|___|\\"],
192
+ [" .----.", " [ -_- ] idle.", " /|___|\\"],
193
+ [" .----.", " [ -_- ] idle..", " /|___|\\"],
194
+ ],
195
+ thinking: [
196
+ [" .----.", " [ o_o ] compute", " /|_?_|"],
197
+ [" .----.", " [ o_o ] compute.", " /|_?_|"],
198
+ [" .----.", " [ o_o ] compute..", " /|_?_|"],
199
+ ],
200
+ tool: [
201
+ [" .----.", " [ >_< ] exec", " /|___|\\"],
202
+ [" .----.", " [ o_o ] scan", " /|___|\\"],
203
+ ],
204
+ chat: [
205
+ [" .----.", " [ ^_^ ] beep", " /|___|\\"],
206
+ [" .----.", " [ o_o ] listen", " /|___|\\"],
207
+ ],
208
+ celebrate: [[" .----.", " [ ^_^ ] pass", " /|___|\\"]],
209
+ concerned: [[" .----.", " [ o_o ] warn", " /|_!_|\\"]],
210
+ },
211
+ };
212
+
213
+ class PetComponent implements Component {
214
+ private profile: PetProfile;
215
+ private mood: PetMood = "idle";
216
+ private frame = 0;
217
+ private theme: Theme;
218
+ private replyLines: string[] = [];
219
+ private idleSince = Date.now();
220
+ private nextFrameAt = 0;
221
+ private blinkUntil = 0;
222
+ private nextBlinkAt = Date.now() + 8_000;
223
+
224
+ constructor(profile: PetProfile, theme: Theme) {
225
+ this.profile = profile;
226
+ this.theme = theme;
227
+ }
228
+
229
+ setProfile(profile: PetProfile): void {
230
+ this.profile = profile;
231
+ }
232
+
233
+ setMood(mood: PetMood): void {
234
+ if (this.mood === mood) return;
235
+ this.mood = mood;
236
+ this.frame = 0;
237
+ this.nextFrameAt = 0;
238
+ if (mood === "idle") {
239
+ this.idleSince = Date.now();
240
+ this.nextBlinkAt = Date.now() + this.nextBlinkDelay();
241
+ }
242
+ }
243
+
244
+ setReply(reply: string): void {
245
+ this.replyLines = reply
246
+ .split("\n")
247
+ .map((line) => line.trim())
248
+ .filter((line) => line.length > 0)
249
+ .slice(0, 4);
250
+ }
251
+
252
+ tick(now = Date.now()): void {
253
+ if (this.mood === "idle" && now >= this.nextBlinkAt) {
254
+ this.blinkUntil = now + 800;
255
+ this.nextBlinkAt = now + this.nextBlinkDelay();
256
+ }
257
+
258
+ if (now < this.nextFrameAt) return;
259
+ this.frame++;
260
+ this.nextFrameAt = now + this.frameInterval();
261
+ }
262
+
263
+ invalidate(): void {}
264
+
265
+ render(width: number): string[] {
266
+ const pose = this.getPose();
267
+ const frames = PET_ART[this.profile.species][pose];
268
+ const art = frames[this.frame % frames.length] ?? frames[0];
269
+ const equipped = getPetItem(this.profile.equippedItemId);
270
+ const charm = equipped ? ` ${this.theme.fg(equipped.color, equipped.glyph)}` : "";
271
+ const title = `${this.profile.name} ${this.profile.rarity} ${this.profile.personality}`;
272
+ const label = this.theme.fg("accent", `pet:${this.profile.species}`);
273
+ const mood = this.theme.fg("dim", this.mood);
274
+ const lines = [` ${label}${charm} ${this.theme.fg("muted", title)} ${mood}`, ...art];
275
+
276
+ for (const line of this.replyLines) {
277
+ lines.push(this.theme.fg("muted", ` < ${line}`));
278
+ }
279
+
280
+ if (this.profile.position !== "widget") {
281
+ return lines.map((line) => truncateToWidth(line, width));
282
+ }
283
+
284
+ const blockWidth = Math.min(width, Math.max(...lines.map((line) => visibleWidth(line))));
285
+ return lines.map((line) => this.rightAlignBlockLine(line, width, blockWidth));
286
+ }
287
+
288
+ private getPose(): PetMood | "blink" | "sleep" {
289
+ if (this.mood !== "idle") return this.mood;
290
+ const now = Date.now();
291
+ if (now < this.blinkUntil) return "blink";
292
+ if (now - this.idleSince > SLEEP_AFTER_MS || this.profile.personality === "sleepy") return "sleep";
293
+ return "idle";
294
+ }
295
+
296
+ private frameInterval(): number {
297
+ switch (this.mood) {
298
+ case "tool":
299
+ return 650;
300
+ case "thinking":
301
+ return 1100;
302
+ case "chat":
303
+ return 900;
304
+ case "celebrate":
305
+ case "concerned":
306
+ return 800;
307
+ case "idle":
308
+ return this.getPose() === "sleep" ? 1800 : 1500;
309
+ }
310
+ }
311
+
312
+ private nextBlinkDelay(): number {
313
+ const base = this.profile.personality === "sleepy" ? 12_000 : 8_000;
314
+ return base + Math.floor(Math.random() * 6_000);
315
+ }
316
+
317
+ private rightAlignBlockLine(line: string, width: number, blockWidth: number): string {
318
+ const content = truncateToWidth(line, Math.max(1, blockWidth));
319
+ const paddedContent = content + " ".repeat(Math.max(0, blockWidth - visibleWidth(content)));
320
+ return " ".repeat(Math.max(0, width - blockWidth)) + paddedContent;
321
+ }
322
+ }
323
+
324
+ function defaultProfile(): PetProfile {
325
+ return {
326
+ name: "Pip",
327
+ species: "cat",
328
+ rarity: "common",
329
+ personality: "curious",
330
+ stats: { focus: 1, energy: 5, curiosity: 7, sass: 2, loyalty: 4 },
331
+ xp: 0,
332
+ level: 1,
333
+ interactions: 0,
334
+ toolsCompleted: 0,
335
+ position: "widget",
336
+ enabled: true,
337
+ feedCount: 0,
338
+ inventory: [],
339
+ };
340
+ }
341
+
342
+ function normalizeProfile(value: unknown): PetProfile | undefined {
343
+ if (!value || typeof value !== "object") return undefined;
344
+ const partial = value as Partial<PetProfile>;
345
+ const fallback = defaultProfile();
346
+ const species = partial.species && PET_KINDS.includes(partial.species) ? partial.species : fallback.species;
347
+ const rarity = partial.rarity && RARITIES.includes(partial.rarity) ? partial.rarity : fallback.rarity;
348
+ const personality =
349
+ partial.personality && PERSONALITIES.includes(partial.personality) ? partial.personality : fallback.personality;
350
+ const position =
351
+ partial.position === "overlay" || partial.position === "widget" ? partial.position : fallback.position;
352
+ const stats = partial.stats ?? fallback.stats;
353
+ const inventory = Array.isArray(partial.inventory)
354
+ ? partial.inventory.map(normalizeInventoryItem).filter((item) => item !== undefined)
355
+ : fallback.inventory;
356
+ const equippedItemId =
357
+ typeof partial.equippedItemId === "string" && PET_ITEMS.some((item) => item.id === partial.equippedItemId)
358
+ ? partial.equippedItemId
359
+ : undefined;
360
+
361
+ return {
362
+ name: typeof partial.name === "string" && partial.name.trim() ? partial.name.trim().slice(0, 24) : fallback.name,
363
+ species,
364
+ rarity,
365
+ personality,
366
+ stats: {
367
+ focus: safeStat(stats.focus, fallback.stats.focus),
368
+ energy: safeStat(stats.energy, fallback.stats.energy),
369
+ curiosity: safeStat(stats.curiosity, fallback.stats.curiosity),
370
+ sass: safeStat(stats.sass, fallback.stats.sass),
371
+ loyalty: safeStat(stats.loyalty, fallback.stats.loyalty),
372
+ },
373
+ xp: safeCount(partial.xp, fallback.xp),
374
+ level: Math.max(1, safeCount(partial.level, fallback.level)),
375
+ interactions: safeCount(partial.interactions, fallback.interactions),
376
+ toolsCompleted: safeCount(partial.toolsCompleted, fallback.toolsCompleted),
377
+ position,
378
+ enabled: typeof partial.enabled === "boolean" ? partial.enabled : fallback.enabled,
379
+ lastCheckInDay: typeof partial.lastCheckInDay === "string" ? partial.lastCheckInDay : undefined,
380
+ feedCount: safeCount(partial.feedCount, fallback.feedCount),
381
+ inventory,
382
+ equippedItemId,
383
+ };
384
+ }
385
+
386
+ function normalizeInventoryItem(value: unknown): PetInventoryItem | undefined {
387
+ if (!value || typeof value !== "object") return undefined;
388
+ const partial = value as Partial<PetInventoryItem>;
389
+ if (typeof partial.itemId !== "string" || !PET_ITEMS.some((item) => item.id === partial.itemId)) return undefined;
390
+ return { itemId: partial.itemId, count: Math.max(1, safeCount(partial.count, 1)) };
391
+ }
392
+
393
+ function safeStat(value: unknown, fallback: number): number {
394
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.min(10, Math.floor(value))) : fallback;
395
+ }
396
+
397
+ function safeCount(value: unknown, fallback: number): number {
398
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : fallback;
399
+ }
400
+
401
+ function parsePetKind(text: string): PetKind | undefined {
402
+ return PET_KINDS.find((kind) => kind === text);
403
+ }
404
+
405
+ function entryToMessage(entry: SessionEntry): AgentMessage | undefined {
406
+ if (entry.type === "message") return entry.message;
407
+ if (entry.type === "compaction") {
408
+ return {
409
+ role: "compactionSummary",
410
+ summary: entry.summary,
411
+ tokensBefore: entry.tokensBefore,
412
+ timestamp: new Date(entry.timestamp).getTime(),
413
+ };
414
+ }
415
+ return undefined;
416
+ }
417
+
418
+ function getContextMessages(branch: SessionEntry[]): AgentMessage[] {
419
+ let compactionIndex = -1;
420
+ for (let i = branch.length - 1; i >= 0; i--) {
421
+ if (branch[i].type === "compaction") {
422
+ compactionIndex = i;
423
+ break;
424
+ }
425
+ }
426
+
427
+ if (compactionIndex < 0) {
428
+ return branch.map(entryToMessage).filter((message) => message !== undefined);
429
+ }
430
+
431
+ const compaction = branch[compactionIndex];
432
+ const firstKeptIndex =
433
+ compaction.type === "compaction" ? branch.findIndex((entry) => entry.id === compaction.firstKeptEntryId) : -1;
434
+ const compactedBranch = [
435
+ compaction,
436
+ ...(firstKeptIndex >= 0 ? branch.slice(firstKeptIndex, compactionIndex) : []),
437
+ ...branch.slice(compactionIndex + 1),
438
+ ];
439
+ return compactedBranch.map(entryToMessage).filter((message) => message !== undefined);
440
+ }
441
+
442
+ function getXpForNextLevel(level: number): number {
443
+ return Math.floor(8 + level * level * 3.5);
444
+ }
445
+
446
+ function getTodayKey(): string {
447
+ return new Date().toISOString().slice(0, 10);
448
+ }
449
+
450
+ function getPetItem(itemId: string | undefined): PetItem | undefined {
451
+ return itemId ? PET_ITEMS.find((item) => item.id === itemId) : undefined;
452
+ }
453
+
454
+ function createStatusCard(profile: PetProfile): string {
455
+ const nextXp = getXpForNextLevel(profile.level);
456
+ const equipped = getPetItem(profile.equippedItemId);
457
+ const itemCount = profile.inventory.reduce((total, item) => total + item.count, 0);
458
+ return [
459
+ `${profile.name} the ${profile.rarity} ${profile.species}`,
460
+ `personality: ${profile.personality} level: ${profile.level} xp: ${profile.xp}/${nextXp}`,
461
+ `stats: focus ${profile.stats.focus}/10, energy ${profile.stats.energy}/10, curiosity ${profile.stats.curiosity}/10`,
462
+ ` sass ${profile.stats.sass}/10, loyalty ${profile.stats.loyalty}/10`,
463
+ `activity: ${profile.interactions} chats, ${profile.toolsCompleted} tools, feeds ${profile.feedCount}`,
464
+ `bag: ${itemCount} items${equipped ? `, equipped ${equipped.name}` : ""} check-in: ${profile.lastCheckInDay ?? "never"}`,
465
+ ].join("\n");
466
+ }
467
+
468
+ function createPetReply(profile: PetProfile, message: string): string {
469
+ const text = message.toLowerCase();
470
+ const sound =
471
+ profile.species === "bot"
472
+ ? "beep"
473
+ : profile.species === "dog"
474
+ ? "woof"
475
+ : profile.species === "fox"
476
+ ? "yip"
477
+ : "meow";
478
+ const suffix = personalitySuffix(profile.personality);
479
+
480
+ if (text.includes("你好") || text.includes("hello") || text.includes("hi")) {
481
+ return `${sound}. 我在这儿。${suffix}`;
482
+ }
483
+ if (text.includes("怎么样") || text.includes("how are")) {
484
+ return `状态不错,${profile.name} 正在看着你的代码。${suffix}`;
485
+ }
486
+ if (text.includes("累") || text.includes("困") || text.includes("sleep")) {
487
+ return profile.personality === "sleepy" ? "我也想睡,但还能再陪你一会儿。" : "可以小憩,但先把 bug 抓完。";
488
+ }
489
+ if (text.includes("bug") || text.includes("错误") || text.includes("报错")) {
490
+ return profile.personality === "snarky" ? "我闻到 bug 了,它躲得不算聪明。" : "我闻到 bug 了,交给我盯着。";
491
+ }
492
+ if (text.includes("谢谢") || text.includes("thanks")) {
493
+ return profile.personality === "loyal" ? "一直在。继续推进。" : "收到。继续推进。";
494
+ }
495
+ if (text.includes("名字") || text.includes("name")) {
496
+ return `我是 ${profile.name},一只 ${profile.rarity} ${profile.species}。`;
497
+ }
498
+
499
+ const replies = personalityReplies(profile.personality);
500
+ let hash = 0;
501
+ for (const char of message) hash = (hash + char.charCodeAt(0)) % replies.length;
502
+ return replies[hash] ?? replies[0];
503
+ }
504
+
505
+ function personalitySuffix(personality: PetPersonality): string {
506
+ switch (personality) {
507
+ case "calm":
508
+ return "慢慢来。";
509
+ case "curious":
510
+ return "要不要继续查?";
511
+ case "snarky":
512
+ return "我会尽量少吐槽。";
513
+ case "loyal":
514
+ return "我守着。";
515
+ case "sleepy":
516
+ return "如果我打盹,叫我。";
517
+ }
518
+ }
519
+
520
+ function personalityReplies(personality: PetPersonality): string[] {
521
+ switch (personality) {
522
+ case "calm":
523
+ return ["我听到了。先稳住节奏。", "这可以慢慢拆。", "别急,先看事实。"];
524
+ case "curious":
525
+ return ["这听起来可以继续深挖。", "要不要让我闻闻上下文?", "我想知道下一步是什么。"];
526
+ case "snarky":
527
+ return ["听起来像代码又想搞事。", "我先不评价,但我有预感。", "这个方向至少不无聊。"];
528
+ case "loyal":
529
+ return ["收到,我陪你看。", "我会盯着进展。", "交给我们一起推进。"];
530
+ case "sleepy":
531
+ return ["我听到了,虽然有点困。", "可以,我先睁一只眼。", "好,记在小枕头旁边。"];
532
+ }
533
+ }
534
+
535
+ async function askPet(question: string, ctx: ExtensionCommandContext): Promise<string | null> {
536
+ if (!ctx.model) {
537
+ ctx.ui.notify("No model selected", "error");
538
+ return null;
539
+ }
540
+
541
+ const contextMessages = getContextMessages(ctx.sessionManager.getBranch());
542
+ if (contextMessages.length === 0) {
543
+ ctx.ui.notify("No conversation context found", "warning");
544
+ return null;
545
+ }
546
+
547
+ const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
548
+ const loader = new BorderedLoader(tui, theme, "Pet is sniffing the current context...");
549
+ loader.onAbort = () => done(null);
550
+
551
+ const generate = async () => {
552
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model!);
553
+ if (!auth.ok || !auth.apiKey) {
554
+ throw new Error(auth.ok ? `No API key for ${ctx.model!.provider}` : auth.error);
555
+ }
556
+
557
+ const conversationText = serializeConversation(convertToLlm(contextMessages));
558
+ const userMessage: Message = {
559
+ role: "user",
560
+ content: [
561
+ {
562
+ type: "text",
563
+ text: `## Current conversation context\n\n${conversationText}\n\n## User question for the pet\n\n${question}`,
564
+ },
565
+ ],
566
+ timestamp: Date.now(),
567
+ };
568
+
569
+ const response = await complete(
570
+ ctx.model!,
571
+ { systemPrompt: PET_ASK_PROMPT, messages: [userMessage] },
572
+ { apiKey: auth.apiKey, headers: auth.headers, signal: loader.signal },
573
+ );
574
+
575
+ if (response.stopReason === "aborted") return null;
576
+ return response.content
577
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
578
+ .map((part) => part.text)
579
+ .join("\n")
580
+ .trim();
581
+ };
582
+
583
+ generate()
584
+ .then(done)
585
+ .catch((error) => {
586
+ ctx.ui.notify(error instanceof Error ? error.message : "Pet ask failed", "error");
587
+ done(null);
588
+ });
589
+
590
+ return loader;
591
+ });
592
+
593
+ return result?.trim() ? result : null;
594
+ }
595
+
596
+ export default function petExtension(pi: ExtensionAPI) {
597
+ let profile = defaultProfile();
598
+ let component: PetComponent | null = null;
599
+ let tui: TUI | null = null;
600
+ let timer: ReturnType<typeof setInterval> | null = null;
601
+ let chatTimer: ReturnType<typeof setTimeout> | null = null;
602
+ let overlayHandle: OverlayHandle | null = null;
603
+ let closeOverlay: (() => void) | null = null;
604
+ let activeToolCount = 0;
605
+ let agentRunning = false;
606
+
607
+ function saveProfile(): void {
608
+ pi.appendEntry(PET_PROFILE_TYPE, profile);
609
+ }
610
+
611
+ function restoreProfile(ctx: ExtensionContext): void {
612
+ const entries = ctx.sessionManager.getEntries();
613
+ for (let i = entries.length - 1; i >= 0; i--) {
614
+ const entry = entries[i];
615
+ if (entry.type !== "custom" || entry.customType !== PET_PROFILE_TYPE) continue;
616
+ const saved = normalizeProfile(entry.data);
617
+ if (saved) profile = saved;
618
+ return;
619
+ }
620
+ }
621
+
622
+ function stopTimer(): void {
623
+ if (!timer) return;
624
+ clearInterval(timer);
625
+ timer = null;
626
+ }
627
+
628
+ function stopChatTimer(): void {
629
+ if (!chatTimer) return;
630
+ clearTimeout(chatTimer);
631
+ chatTimer = null;
632
+ }
633
+
634
+ function startTimer(): void {
635
+ stopTimer();
636
+ timer = setInterval(() => {
637
+ component?.tick();
638
+ tui?.requestRender();
639
+ }, BASE_TICK_MS);
640
+ }
641
+
642
+ function updateStatus(ctx: ExtensionContext): void {
643
+ if (!ctx.hasUI) return;
644
+ const text = profile.enabled ? ctx.ui.theme.fg("dim", `Pet: ${profile.name} L${profile.level}`) : undefined;
645
+ ctx.ui.setStatus("pet", text);
646
+ }
647
+
648
+ function setMood(mood: PetMood): void {
649
+ component?.setMood(mood);
650
+ tui?.requestRender();
651
+ }
652
+
653
+ function restoreActivityMood(): void {
654
+ setMood(activeToolCount > 0 ? "tool" : agentRunning ? "thinking" : "idle");
655
+ }
656
+
657
+ function setReply(message: string, mood: PetMood = "chat", ttlMs = CHAT_MOOD_MS): void {
658
+ component?.setReply(message);
659
+ setMood(mood);
660
+ stopChatTimer();
661
+ chatTimer = setTimeout(() => {
662
+ component?.setReply("");
663
+ restoreActivityMood();
664
+ }, ttlMs);
665
+ tui?.requestRender();
666
+ }
667
+
668
+ function gainXp(amount: number): void {
669
+ profile.xp += amount;
670
+ let leveled = false;
671
+ while (profile.xp >= getXpForNextLevel(profile.level)) {
672
+ profile.xp -= getXpForNextLevel(profile.level);
673
+ profile.level++;
674
+ leveled = true;
675
+ }
676
+ if (leveled) {
677
+ setReply(`${profile.name} reached level ${profile.level}.`, "celebrate");
678
+ }
679
+ }
680
+
681
+ function bumpStat(key: keyof PetStats, amount = 1): void {
682
+ profile.stats[key] = Math.max(0, Math.min(10, profile.stats[key] + amount));
683
+ }
684
+
685
+ function addItem(item: PetItem): void {
686
+ const existing = profile.inventory.find((inventoryItem) => inventoryItem.itemId === item.id);
687
+ if (existing) existing.count++;
688
+ else profile.inventory.push({ itemId: item.id, count: 1 });
689
+ }
690
+
691
+ function rollDrop(trigger: PetItemTrigger, chance: number): PetItem | undefined {
692
+ if (Math.random() >= chance) return undefined;
693
+ const candidates = PET_ITEMS.filter((item) => item.trigger === trigger);
694
+ if (candidates.length === 0) return undefined;
695
+ const roll = Math.random();
696
+ const maxRarity: PetItemRarity =
697
+ roll < 0.82 ? "common" : roll < 0.97 ? "rare" : roll < 0.995 ? "epic" : "legendary";
698
+ const rarityOrder: PetItemRarity[] = ["common", "rare", "epic", "legendary"];
699
+ const maxIndex = rarityOrder.indexOf(maxRarity);
700
+ const pool = candidates.filter((item) => rarityOrder.indexOf(item.rarity) <= maxIndex);
701
+ return pool[Math.floor(Math.random() * pool.length)];
702
+ }
703
+
704
+ function maybeDropItem(trigger: PetItemTrigger, chance: number): PetItem | undefined {
705
+ const item = rollDrop(trigger, chance);
706
+ if (!item) return undefined;
707
+ addItem(item);
708
+ setReply(`Found ${item.rarity} item: ${item.name} ${item.glyph}`, "celebrate", 7000);
709
+ return item;
710
+ }
711
+
712
+ function createBagCard(): string {
713
+ if (profile.inventory.length === 0) return "Bag is empty. Try /pet checkin or keep working with tools.";
714
+ return profile.inventory
715
+ .map((inventoryItem) => {
716
+ const item = getPetItem(inventoryItem.itemId);
717
+ if (!item) return undefined;
718
+ const equipped = profile.equippedItemId === item.id ? " equipped" : "";
719
+ return `${item.glyph} ${item.name} (${item.rarity}) x${inventoryItem.count}${equipped}`;
720
+ })
721
+ .filter((line) => line !== undefined)
722
+ .join("\n");
723
+ }
724
+
725
+ function talkToPet(message: string): string {
726
+ profile.interactions++;
727
+ bumpStat("loyalty", /谢谢|thanks/i.test(message) ? 1 : 0);
728
+ gainXp(1);
729
+ const reply = createPetReply(profile, message);
730
+ setReply(reply);
731
+ saveProfile();
732
+ return reply;
733
+ }
734
+
735
+ function syncComponentProfile(): void {
736
+ component?.setProfile(profile);
737
+ }
738
+
739
+ function clearOverlay(): void {
740
+ overlayHandle?.hide();
741
+ overlayHandle = null;
742
+ closeOverlay?.();
743
+ closeOverlay = null;
744
+ }
745
+
746
+ function applyWidget(ctx: ExtensionContext): void {
747
+ if (ctx.mode !== "tui") return;
748
+ ctx.ui.setWidget("pet", undefined, { placement: "aboveEditor" });
749
+ clearOverlay();
750
+
751
+ if (!profile.enabled) {
752
+ component = null;
753
+ tui = null;
754
+ stopTimer();
755
+ stopChatTimer();
756
+ updateStatus(ctx);
757
+ return;
758
+ }
759
+
760
+ ctx.ui.setWidget(
761
+ "pet",
762
+ (nextTui, theme) => {
763
+ tui = nextTui;
764
+ component = new PetComponent(profile, theme);
765
+ restoreActivityMood();
766
+ return component;
767
+ },
768
+ { placement: "aboveEditor" },
769
+ );
770
+ startTimer();
771
+ updateStatus(ctx);
772
+ }
773
+
774
+ function applyOverlay(ctx: ExtensionContext): void {
775
+ if (ctx.mode !== "tui") return;
776
+ ctx.ui.setWidget("pet", undefined, { placement: "aboveEditor" });
777
+ clearOverlay();
778
+
779
+ if (!profile.enabled) {
780
+ component = null;
781
+ tui = null;
782
+ stopTimer();
783
+ stopChatTimer();
784
+ updateStatus(ctx);
785
+ return;
786
+ }
787
+
788
+ void ctx.ui
789
+ .custom<void>(
790
+ (nextTui, theme, _kb, done) => {
791
+ tui = nextTui;
792
+ closeOverlay = () => done(undefined);
793
+ component = new PetComponent(profile, theme);
794
+ restoreActivityMood();
795
+ return component;
796
+ },
797
+ {
798
+ overlay: true,
799
+ overlayOptions: {
800
+ anchor: "bottom-right",
801
+ width: 38,
802
+ margin: { right: 2, bottom: 4 },
803
+ nonCapturing: true,
804
+ },
805
+ onHandle: (handle) => {
806
+ overlayHandle = handle;
807
+ },
808
+ },
809
+ )
810
+ .catch(() => {});
811
+ startTimer();
812
+ updateStatus(ctx);
813
+ }
814
+
815
+ function applyPet(ctx: ExtensionContext): void {
816
+ if (profile.position === "overlay") applyOverlay(ctx);
817
+ else applyWidget(ctx);
818
+ }
819
+
820
+ pi.on("session_start", async (_event, ctx) => {
821
+ restoreProfile(ctx);
822
+ applyPet(ctx);
823
+ });
824
+
825
+ pi.on("agent_start", async (_event, _ctx) => {
826
+ agentRunning = true;
827
+ activeToolCount = 0;
828
+ if (!chatTimer) setMood("thinking");
829
+ });
830
+
831
+ pi.on("tool_execution_start", async (_event, _ctx) => {
832
+ activeToolCount++;
833
+ if (!chatTimer) setMood("tool");
834
+ });
835
+
836
+ pi.on("tool_execution_end", async (event, _ctx) => {
837
+ activeToolCount = Math.max(0, activeToolCount - 1);
838
+ profile.toolsCompleted++;
839
+ bumpStat(event.isError ? "sass" : "focus", 1);
840
+ gainXp(event.isError ? 0 : 1);
841
+ if (!event.isError) {
842
+ const isMemoryTool = event.toolName.toLowerCase().includes("memory");
843
+ maybeDropItem(isMemoryTool ? "memory" : "tool", isMemoryTool ? MEMORY_DROP_CHANCE : TOOL_DROP_CHANCE);
844
+ }
845
+ if (event.isError && !chatTimer) setMood("concerned");
846
+ else if (activeToolCount === 0 && !chatTimer) setMood("thinking");
847
+ saveProfile();
848
+ });
849
+
850
+ pi.on("agent_end", async (_event, _ctx) => {
851
+ agentRunning = false;
852
+ activeToolCount = 0;
853
+ bumpStat("energy", 1);
854
+ gainXp(1);
855
+ if (!chatTimer) setMood("idle");
856
+ saveProfile();
857
+ });
858
+
859
+ pi.on("session_shutdown", async (_event, ctx) => {
860
+ stopTimer();
861
+ stopChatTimer();
862
+ clearOverlay();
863
+ if (ctx.hasUI) ctx.ui.setStatus("pet", undefined);
864
+ });
865
+
866
+ pi.registerCommand("pet", {
867
+ description: "Show, hide, switch, talk to, or ask the terminal pet about current context.",
868
+ handler: async (args, ctx) => {
869
+ const raw = args.trim();
870
+ const next = raw.toLowerCase();
871
+ if (!next) {
872
+ setReply(createStatusCard(profile), "chat", 8000);
873
+ ctx.ui.notify(
874
+ `${profile.name}: ${profile.species}, ${profile.personality}, level ${profile.level}`,
875
+ "info",
876
+ );
877
+ return;
878
+ }
879
+
880
+ if (next === "on") {
881
+ profile.enabled = true;
882
+ applyPet(ctx);
883
+ saveProfile();
884
+ ctx.ui.notify("Pet enabled", "info");
885
+ return;
886
+ }
887
+
888
+ if (next === "off") {
889
+ profile.enabled = false;
890
+ applyPet(ctx);
891
+ saveProfile();
892
+ ctx.ui.notify("Pet hidden", "info");
893
+ return;
894
+ }
895
+
896
+ if (next === "mood") {
897
+ setReply(createStatusCard(profile), "chat", 8000);
898
+ ctx.ui.notify("Pet mood shown", "info");
899
+ return;
900
+ }
901
+
902
+ if (next === "bag" || next === "inventory") {
903
+ setReply(createBagCard(), "chat", 9000);
904
+ ctx.ui.notify("Pet bag shown", "info");
905
+ return;
906
+ }
907
+
908
+ if (next === "checkin" || next === "签到") {
909
+ const today = getTodayKey();
910
+ if (profile.lastCheckInDay === today) {
911
+ setReply("今天已经签到过了。明天再来。", "chat");
912
+ return;
913
+ }
914
+ profile.lastCheckInDay = today;
915
+ gainXp(DAILY_CHECKIN_XP);
916
+ bumpStat("loyalty", 1);
917
+ const item = maybeDropItem("checkin", CHECKIN_DROP_CHANCE);
918
+ if (!item) setReply(`签到完成。${profile.name} +${DAILY_CHECKIN_XP} xp。`, "celebrate");
919
+ saveProfile();
920
+ return;
921
+ }
922
+
923
+ if (next === "feed" || next === "喂食") {
924
+ profile.feedCount++;
925
+ gainXp(FEED_XP);
926
+ bumpStat("energy", 1);
927
+ setReply(`${profile.name} 吃饱了。+${FEED_XP} xp。`, "celebrate");
928
+ saveProfile();
929
+ return;
930
+ }
931
+
932
+ if (next === "reset") {
933
+ profile = defaultProfile();
934
+ syncComponentProfile();
935
+ applyPet(ctx);
936
+ saveProfile();
937
+ ctx.ui.notify("Pet profile reset", "info");
938
+ return;
939
+ }
940
+
941
+ if (next.startsWith("equip ")) {
942
+ const itemName = raw.slice(6).trim().toLowerCase();
943
+ const inventoryItem = profile.inventory.find((owned) => {
944
+ const item = getPetItem(owned.itemId);
945
+ return item?.id === itemName || item?.name.toLowerCase() === itemName;
946
+ });
947
+ if (!inventoryItem) {
948
+ ctx.ui.notify("Item not found in bag. Use /pet bag.", "error");
949
+ return;
950
+ }
951
+ profile.equippedItemId = inventoryItem.itemId;
952
+ syncComponentProfile();
953
+ const item = getPetItem(inventoryItem.itemId);
954
+ setReply(item ? `Equipped ${item.name} ${item.glyph}.` : "Equipped item.");
955
+ saveProfile();
956
+ return;
957
+ }
958
+
959
+ if (next === "unequip") {
960
+ profile.equippedItemId = undefined;
961
+ syncComponentProfile();
962
+ setReply("Item removed.");
963
+ saveProfile();
964
+ return;
965
+ }
966
+
967
+ if (next.startsWith("name ")) {
968
+ const name = raw.slice(5).trim();
969
+ if (!name) {
970
+ ctx.ui.notify("Usage: /pet name <name>", "error");
971
+ return;
972
+ }
973
+ profile.name = name.slice(0, 24);
974
+ syncComponentProfile();
975
+ setReply(`现在叫我 ${profile.name}。`);
976
+ saveProfile();
977
+ return;
978
+ }
979
+
980
+ if (next.startsWith("position ")) {
981
+ const position = next.slice(9).trim();
982
+ if (position !== "widget" && position !== "overlay") {
983
+ ctx.ui.notify("Usage: /pet position [widget|overlay]", "error");
984
+ return;
985
+ }
986
+ profile.position = position;
987
+ applyPet(ctx);
988
+ saveProfile();
989
+ ctx.ui.notify(`Pet position set to ${position}`, "info");
990
+ return;
991
+ }
992
+
993
+ if (next.startsWith("ask ")) {
994
+ const question = raw.slice(4).trim();
995
+ if (!question) {
996
+ ctx.ui.notify("Usage: /pet ask <question>", "error");
997
+ return;
998
+ }
999
+ if (!profile.enabled) {
1000
+ profile.enabled = true;
1001
+ applyPet(ctx);
1002
+ }
1003
+ bumpStat("curiosity", 1);
1004
+ setReply("我先看看当前上下文...");
1005
+ const answer = await askPet(question, ctx);
1006
+ if (answer) {
1007
+ profile.interactions++;
1008
+ gainXp(1);
1009
+ setReply(answer, "chat", 9000);
1010
+ saveProfile();
1011
+ }
1012
+ return;
1013
+ }
1014
+
1015
+ const petKind = parsePetKind(next);
1016
+ if (petKind) {
1017
+ profile.species = petKind;
1018
+ syncComponentProfile();
1019
+ if (profile.enabled) applyPet(ctx);
1020
+ else updateStatus(ctx);
1021
+ saveProfile();
1022
+ ctx.ui.notify(`Pet switched to ${petKind}`, "info");
1023
+ return;
1024
+ }
1025
+
1026
+ if (!profile.enabled) {
1027
+ profile.enabled = true;
1028
+ applyPet(ctx);
1029
+ }
1030
+ ctx.ui.notify(talkToPet(raw), "info");
1031
+ },
1032
+ });
1033
+ }