drexler 0.2.14 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +1 -1
- package/src/commands.ts +127 -20
- package/src/config.ts +141 -32
- package/src/conversation.ts +0 -4
- package/src/index.ts +68 -5
- package/src/pet/petState.ts +408 -0
- package/src/repl.ts +1 -1
- package/src/ui/App.tsx +543 -144
- package/src/ui/CommandPalette.tsx +2 -0
- package/src/ui/DealDeskHeader.tsx +0 -5
- package/src/ui/DeathScreen.tsx +110 -0
- package/src/ui/MarkdownBody.tsx +29 -5
- package/src/ui/MascotIntro.tsx +158 -57
- package/src/ui/Message.tsx +2 -105
- package/src/ui/PetPanel.tsx +537 -0
- package/src/ui/TranscriptViewport.tsx +206 -48
- package/src/ui/displayContent.ts +5 -2
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import {
|
|
2
|
+
existsSync,
|
|
3
|
+
mkdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { homedir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
export type PetActivity =
|
|
13
|
+
| "idle"
|
|
14
|
+
| "eating"
|
|
15
|
+
| "playing"
|
|
16
|
+
| "working"
|
|
17
|
+
| "sleeping"
|
|
18
|
+
| "praised"
|
|
19
|
+
| "vibing";
|
|
20
|
+
|
|
21
|
+
export interface PetStats {
|
|
22
|
+
hunger: number;
|
|
23
|
+
happiness: number;
|
|
24
|
+
energy: number;
|
|
25
|
+
deals: number;
|
|
26
|
+
lastSaved: number;
|
|
27
|
+
dead?: boolean;
|
|
28
|
+
name?: string;
|
|
29
|
+
createdAt?: number;
|
|
30
|
+
lastActionAt?: Partial<Record<PetActionKey, number>>;
|
|
31
|
+
lifetimeDeals?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MAX_NAME_LEN = 16;
|
|
35
|
+
const NAME_SANITIZE_RE = /[^\p{L}\p{N} ._'-]/gu;
|
|
36
|
+
|
|
37
|
+
export function sanitizePetName(input: string): string {
|
|
38
|
+
const cleaned = input
|
|
39
|
+
.normalize("NFKC")
|
|
40
|
+
.replace(NAME_SANITIZE_RE, "")
|
|
41
|
+
.replace(/\s+/g, " ")
|
|
42
|
+
.trim();
|
|
43
|
+
return cleaned.slice(0, MAX_NAME_LEN);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Per-hour decay rates
|
|
47
|
+
const DECAY_PER_HOUR = {
|
|
48
|
+
hunger: 15,
|
|
49
|
+
happiness: 8,
|
|
50
|
+
energy: 10,
|
|
51
|
+
deals: 5,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type PetActionKey =
|
|
55
|
+
| "feed"
|
|
56
|
+
| "play"
|
|
57
|
+
| "work"
|
|
58
|
+
| "praise"
|
|
59
|
+
| "rest"
|
|
60
|
+
| "vibe";
|
|
61
|
+
|
|
62
|
+
export const PET_COOLDOWN_MS = 90_000;
|
|
63
|
+
|
|
64
|
+
interface CooldownCheck {
|
|
65
|
+
ok: boolean;
|
|
66
|
+
remainingMs: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function actionCooldown(
|
|
70
|
+
stats: PetStats,
|
|
71
|
+
action: PetActionKey,
|
|
72
|
+
now: number = Date.now(),
|
|
73
|
+
): CooldownCheck {
|
|
74
|
+
const last = stats.lastActionAt?.[action];
|
|
75
|
+
if (typeof last !== "number" || !Number.isFinite(last)) {
|
|
76
|
+
return { ok: true, remainingMs: 0 };
|
|
77
|
+
}
|
|
78
|
+
const elapsed = now - last;
|
|
79
|
+
if (elapsed >= PET_COOLDOWN_MS) return { ok: true, remainingMs: 0 };
|
|
80
|
+
return { ok: false, remainingMs: PET_COOLDOWN_MS - elapsed };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function stampAction(
|
|
84
|
+
stats: PetStats,
|
|
85
|
+
action: PetActionKey,
|
|
86
|
+
now: number = Date.now(),
|
|
87
|
+
): PetStats {
|
|
88
|
+
return {
|
|
89
|
+
...stats,
|
|
90
|
+
lastActionAt: { ...(stats.lastActionAt ?? {}), [action]: now },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatCooldownRemaining(remainingMs: number): string {
|
|
95
|
+
const secs = Math.max(1, Math.ceil(remainingMs / 1000));
|
|
96
|
+
if (secs < 60) return `${secs}s`;
|
|
97
|
+
const m = Math.floor(secs / 60);
|
|
98
|
+
const s = secs % 60;
|
|
99
|
+
return s === 0 ? `${m}m` : `${m}m ${s}s`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const DEFAULT_STATS: PetStats = {
|
|
103
|
+
hunger: 80,
|
|
104
|
+
happiness: 75,
|
|
105
|
+
energy: 85,
|
|
106
|
+
deals: 30,
|
|
107
|
+
lastSaved: Date.now(),
|
|
108
|
+
createdAt: Date.now(),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function getHome(): string {
|
|
112
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function petDir(): string {
|
|
116
|
+
return join(getHome(), ".drexler");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function petFile(): string {
|
|
120
|
+
return join(petDir(), "pet.json");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function defaultStats(): PetStats {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
return { ...DEFAULT_STATS, lastSaved: now, createdAt: now };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function clamp(v: unknown, fallback = 0): number {
|
|
129
|
+
const n = typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
130
|
+
return Math.max(0, Math.min(100, n));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function safeTimestamp(value: unknown): number {
|
|
134
|
+
return typeof value === "number" && Number.isFinite(value)
|
|
135
|
+
? value
|
|
136
|
+
: Date.now();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function applyDecay(stats: PetStats): PetStats {
|
|
140
|
+
const elapsed = Math.max(0, (Date.now() - stats.lastSaved) / 3_600_000);
|
|
141
|
+
return {
|
|
142
|
+
...stats,
|
|
143
|
+
hunger: clamp(stats.hunger - DECAY_PER_HOUR.hunger * elapsed),
|
|
144
|
+
happiness: clamp(stats.happiness - DECAY_PER_HOUR.happiness * elapsed),
|
|
145
|
+
energy: clamp(stats.energy - DECAY_PER_HOUR.energy * elapsed),
|
|
146
|
+
deals: clamp(stats.deals - DECAY_PER_HOUR.deals * elapsed),
|
|
147
|
+
lastSaved: Date.now(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function loadPetState(): PetStats {
|
|
152
|
+
try {
|
|
153
|
+
const target = petFile();
|
|
154
|
+
if (existsSync(target)) {
|
|
155
|
+
const raw = readFileSync(target, "utf8");
|
|
156
|
+
const parsed = JSON.parse(raw) as Partial<PetStats>;
|
|
157
|
+
if (parsed.dead === true) {
|
|
158
|
+
// Drexler died — reset to halfway on next startup
|
|
159
|
+
const revived = {
|
|
160
|
+
...defaultStats(),
|
|
161
|
+
hunger: 50,
|
|
162
|
+
happiness: 50,
|
|
163
|
+
energy: 50,
|
|
164
|
+
deals: 25,
|
|
165
|
+
};
|
|
166
|
+
writeFileSync(target, JSON.stringify(revived, null, 2));
|
|
167
|
+
return revived;
|
|
168
|
+
}
|
|
169
|
+
const lastActionAt: Partial<Record<PetActionKey, number>> = {};
|
|
170
|
+
const rawActions = parsed.lastActionAt;
|
|
171
|
+
if (rawActions && typeof rawActions === "object") {
|
|
172
|
+
for (const key of ["feed", "play", "work", "praise", "rest", "vibe"] as const) {
|
|
173
|
+
const v = (rawActions as Record<string, unknown>)[key];
|
|
174
|
+
if (typeof v === "number" && Number.isFinite(v)) {
|
|
175
|
+
lastActionAt[key] = v;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const stats: PetStats = {
|
|
180
|
+
hunger: clamp(parsed.hunger, DEFAULT_STATS.hunger),
|
|
181
|
+
happiness: clamp(parsed.happiness, DEFAULT_STATS.happiness),
|
|
182
|
+
energy: clamp(parsed.energy, DEFAULT_STATS.energy),
|
|
183
|
+
deals: clamp(parsed.deals, DEFAULT_STATS.deals),
|
|
184
|
+
lastSaved: safeTimestamp(parsed.lastSaved),
|
|
185
|
+
createdAt:
|
|
186
|
+
typeof parsed.createdAt === "number" &&
|
|
187
|
+
Number.isFinite(parsed.createdAt)
|
|
188
|
+
? parsed.createdAt
|
|
189
|
+
: Date.now(),
|
|
190
|
+
name:
|
|
191
|
+
typeof parsed.name === "string" && parsed.name.length > 0
|
|
192
|
+
? sanitizePetName(parsed.name)
|
|
193
|
+
: undefined,
|
|
194
|
+
lastActionAt: Object.keys(lastActionAt).length > 0 ? lastActionAt : undefined,
|
|
195
|
+
lifetimeDeals:
|
|
196
|
+
typeof parsed.lifetimeDeals === "number" &&
|
|
197
|
+
Number.isFinite(parsed.lifetimeDeals) &&
|
|
198
|
+
parsed.lifetimeDeals >= 0
|
|
199
|
+
? parsed.lifetimeDeals
|
|
200
|
+
: undefined,
|
|
201
|
+
};
|
|
202
|
+
return applyDecay(stats);
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// fall through to defaults
|
|
206
|
+
}
|
|
207
|
+
return defaultStats();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function savePetState(stats: PetStats): void {
|
|
211
|
+
try {
|
|
212
|
+
const dir = petDir();
|
|
213
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
214
|
+
const target = petFile();
|
|
215
|
+
// Atomic write: temp + rename so a crash mid-write leaves the prior
|
|
216
|
+
// pet.json intact rather than a truncated zero-byte file.
|
|
217
|
+
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
218
|
+
try {
|
|
219
|
+
writeFileSync(
|
|
220
|
+
tmp,
|
|
221
|
+
JSON.stringify({ ...stats, lastSaved: Date.now() }, null, 2),
|
|
222
|
+
);
|
|
223
|
+
renameSync(tmp, target);
|
|
224
|
+
} catch (err) {
|
|
225
|
+
try {
|
|
226
|
+
unlinkSync(tmp);
|
|
227
|
+
} catch {}
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
} catch {
|
|
231
|
+
// best-effort
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function applyFeed(stats: PetStats): PetStats {
|
|
236
|
+
return {
|
|
237
|
+
...stats,
|
|
238
|
+
hunger: clamp(stats.hunger + 25),
|
|
239
|
+
happiness: clamp(stats.happiness + 5),
|
|
240
|
+
deals: clamp(stats.deals + 10),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function applyPlay(stats: PetStats): PetStats {
|
|
245
|
+
return {
|
|
246
|
+
...stats,
|
|
247
|
+
happiness: clamp(stats.happiness + 20),
|
|
248
|
+
energy: clamp(stats.energy - 10),
|
|
249
|
+
deals: clamp(stats.deals + 5),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function applyWork(stats: PetStats): PetStats {
|
|
254
|
+
return {
|
|
255
|
+
...stats,
|
|
256
|
+
deals: clamp(stats.deals + 20),
|
|
257
|
+
energy: clamp(stats.energy - 15),
|
|
258
|
+
hunger: clamp(stats.hunger - 5),
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function applyPraise(stats: PetStats): PetStats {
|
|
263
|
+
return { ...stats, happiness: clamp(stats.happiness + 15) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function applyVibe(stats: PetStats): { stats: PetStats; message: string } {
|
|
267
|
+
if (stats.energy < 30) {
|
|
268
|
+
return {
|
|
269
|
+
stats: { ...stats, energy: clamp(stats.energy + 20) },
|
|
270
|
+
message: "Drexler naps briefly under desk. Power restored.",
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
if (stats.hunger < 30) {
|
|
274
|
+
return {
|
|
275
|
+
stats: applyFeed(stats),
|
|
276
|
+
message: "Drexler finds a forgotten deal memo and eats it.",
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
const roll = Math.random();
|
|
280
|
+
if (roll < 0.25) {
|
|
281
|
+
return {
|
|
282
|
+
stats: { ...stats, happiness: clamp(stats.happiness + 10), deals: clamp(stats.deals + 15) },
|
|
283
|
+
message: "Drexler does spontaneous deal origination. Numbers climbing.",
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (roll < 0.5) {
|
|
287
|
+
return {
|
|
288
|
+
stats: { ...stats, happiness: clamp(stats.happiness + 8) },
|
|
289
|
+
message: "Drexler stares out window. Market conditions assessed.",
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
if (roll < 0.75) {
|
|
293
|
+
return {
|
|
294
|
+
stats: { ...stats, energy: clamp(stats.energy + 10) },
|
|
295
|
+
message: "Drexler conducts standing meeting with himself. Productive.",
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
return {
|
|
299
|
+
stats: { ...stats, happiness: clamp(stats.happiness + 12), energy: clamp(stats.energy - 5) },
|
|
300
|
+
message: "Drexler practices pitch deck delivery to the plant.",
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function applyRest(stats: PetStats): PetStats {
|
|
305
|
+
return {
|
|
306
|
+
...stats,
|
|
307
|
+
energy: clamp(stats.energy + 30),
|
|
308
|
+
happiness: clamp(stats.happiness + 5),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function applyMinuteDecay(stats: PetStats): PetStats {
|
|
313
|
+
const rate = 1 / 60;
|
|
314
|
+
return {
|
|
315
|
+
...stats,
|
|
316
|
+
hunger: clamp(stats.hunger - DECAY_PER_HOUR.hunger * rate),
|
|
317
|
+
happiness: clamp(stats.happiness - DECAY_PER_HOUR.happiness * rate),
|
|
318
|
+
energy: clamp(stats.energy - DECAY_PER_HOUR.energy * rate),
|
|
319
|
+
deals: clamp(stats.deals - DECAY_PER_HOUR.deals * rate),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function isPetDead(stats: PetStats): boolean {
|
|
324
|
+
return stats.hunger <= 0 || stats.happiness <= 0 || stats.energy <= 0;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export type PetRank = "intern" | "analyst" | "associate" | "vp" | "md";
|
|
328
|
+
|
|
329
|
+
const RANK_THRESHOLDS: ReadonlyArray<{ threshold: number; rank: PetRank }> = [
|
|
330
|
+
{ threshold: 0, rank: "intern" },
|
|
331
|
+
{ threshold: 200, rank: "analyst" },
|
|
332
|
+
{ threshold: 400, rank: "associate" },
|
|
333
|
+
{ threshold: 600, rank: "vp" },
|
|
334
|
+
{ threshold: 800, rank: "md" },
|
|
335
|
+
];
|
|
336
|
+
|
|
337
|
+
// Lifetime deal count drives rank progression. We track it separately from
|
|
338
|
+
// the volatile `deals` stat so decay/spam don't roll a pet back to intern.
|
|
339
|
+
export function lifetimeDeals(stats: PetStats): number {
|
|
340
|
+
if (typeof stats.lifetimeDeals === "number" && Number.isFinite(stats.lifetimeDeals)) {
|
|
341
|
+
return Math.max(0, stats.lifetimeDeals);
|
|
342
|
+
}
|
|
343
|
+
return stats.deals;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function getPetRank(stats: PetStats): PetRank {
|
|
347
|
+
const total = lifetimeDeals(stats);
|
|
348
|
+
let current: PetRank = "intern";
|
|
349
|
+
for (const tier of RANK_THRESHOLDS) {
|
|
350
|
+
if (total >= tier.threshold) current = tier.rank;
|
|
351
|
+
}
|
|
352
|
+
return current;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export function rankLabel(rank: PetRank): string {
|
|
356
|
+
switch (rank) {
|
|
357
|
+
case "intern": return "Intern";
|
|
358
|
+
case "analyst": return "Analyst";
|
|
359
|
+
case "associate": return "Associate";
|
|
360
|
+
case "vp": return "Vice President";
|
|
361
|
+
case "md": return "Managing Director";
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const RANK_INCREMENTS: Record<Exclude<PetActionKey, "rest" | "praise">, number> = {
|
|
366
|
+
feed: 2,
|
|
367
|
+
play: 1,
|
|
368
|
+
work: 8,
|
|
369
|
+
vibe: 3,
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export function accrueLifetimeDeals(stats: PetStats, action: PetActionKey): PetStats {
|
|
373
|
+
if (action === "rest" || action === "praise") return stats;
|
|
374
|
+
const inc = RANK_INCREMENTS[action];
|
|
375
|
+
const next = lifetimeDeals(stats) + inc;
|
|
376
|
+
return { ...stats, lifetimeDeals: next };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function applyName(stats: PetStats, name: string): PetStats {
|
|
380
|
+
const cleaned = sanitizePetName(name);
|
|
381
|
+
return { ...stats, name: cleaned.length > 0 ? cleaned : undefined };
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
export function petTenureMs(stats: PetStats, now: number = Date.now()): number {
|
|
385
|
+
if (typeof stats.createdAt !== "number" || !Number.isFinite(stats.createdAt)) {
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
return Math.max(0, now - stats.createdAt);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
export function formatTenure(ms: number): string {
|
|
392
|
+
const seconds = Math.floor(ms / 1000);
|
|
393
|
+
const days = Math.floor(seconds / 86400);
|
|
394
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
395
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
396
|
+
if (days > 0) return `${days}d ${hours}h`;
|
|
397
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
398
|
+
return `${minutes}m`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export function getPetMood(stats: PetStats): string {
|
|
402
|
+
if (stats.energy < 25) return "exhausted";
|
|
403
|
+
if (stats.hunger < 25) return "hungry";
|
|
404
|
+
if (stats.happiness > 80) return "manic";
|
|
405
|
+
if (stats.happiness < 30) return "distressed";
|
|
406
|
+
if (stats.deals > 80) return "victorious";
|
|
407
|
+
return "operational";
|
|
408
|
+
}
|
package/src/repl.ts
CHANGED
|
@@ -70,7 +70,7 @@ export function buildMessagesWithReminder(conv: Conversation): Message[] {
|
|
|
70
70
|
|
|
71
71
|
// Confusable letters that look like Latin "I" — fold to ASCII before regex
|
|
72
72
|
// so detection isn't bypassed by Cyrillic І, Turkish İ, fullwidth I, etc.
|
|
73
|
-
const I_CONFUSABLES_RE = /[
|
|
73
|
+
const I_CONFUSABLES_RE = /[ІіİıIℐⅠ]/g;
|
|
74
74
|
|
|
75
75
|
export function detectPersonaDrift(content: string): boolean {
|
|
76
76
|
const noCode = content
|