drexler 0.2.14 → 0.2.16

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.
@@ -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 = /[ІіİıIℐ]/g;
73
+ const I_CONFUSABLES_RE = /[ІіİıIℐⅠ]/g;
74
74
 
75
75
  export function detectPersonaDrift(content: string): boolean {
76
76
  const noCode = content