drexler 0.2.13 → 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/src/index.ts CHANGED
@@ -3,7 +3,12 @@ import { readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import React from "react";
5
5
  import { render } from "ink";
6
- import { ensureApiKey, resolveConfig } from "./config.ts";
6
+ import {
7
+ ensureApiKey,
8
+ LaunchConfigError,
9
+ resolveConfig,
10
+ validateLaunchConfig,
11
+ } from "./config.ts";
7
12
  import { Conversation } from "./conversation.ts";
8
13
  import { moodLine, pickMood } from "./mood.ts";
9
14
  import { loadPersona, pickGreeting } from "./persona.ts";
@@ -52,6 +57,12 @@ Slash commands inside REPL:
52
57
  /clear reset conversation
53
58
  /exit exit
54
59
  /synergy SYNERGY!
60
+ /feed feed Drexler a deal memo
61
+ /play corporate synergy game (flexing included)
62
+ /work Drexler grinds the pipeline
63
+ /rest strategic nap (restores energy)
64
+ /praise affirm Drexler's contributions
65
+ /vibe let Drexler choose his own adventure
55
66
  /model [id] show or switch model
56
67
  /theme [name] show or switch theme; append save to persist
57
68
  /startup [mode] persist startup mode: fast, no-intro, normal
@@ -66,6 +77,8 @@ Slash commands inside REPL:
66
77
  /save [path] archive conversation as markdown
67
78
  /save-last [path] save latest response
68
79
  /copy-last copy latest response to clipboard
80
+ /setup show config + API key source
81
+ /update show upgrade instructions
69
82
 
70
83
  Ctrl+C exits gracefully.`;
71
84
 
@@ -85,18 +98,39 @@ async function main(): Promise<void> {
85
98
  const isInteractive =
86
99
  process.stdout.isTTY === true && process.stdin.isTTY === true;
87
100
 
88
- // Acquire API key. Prompts interactively if missing runs BEFORE banner.
101
+ // 1. Validate non-secret config FIRST so a bogus --model or --persona
102
+ // fails fast before we ask the user for an API key.
103
+ try {
104
+ await validateLaunchConfig(argv);
105
+ } catch (e) {
106
+ if (e instanceof LaunchConfigError) {
107
+ console.error(error(formatLaunchError(e)));
108
+ } else {
109
+ console.error(
110
+ error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
111
+ );
112
+ }
113
+ process.exit(1);
114
+ }
115
+
116
+ // 2. Acquire API key (may prompt). Runs after validation so bad CLI args
117
+ // no longer trigger the first-run setup flow.
89
118
  await ensureApiKey({
90
119
  prompt: isInteractive ? promptForApiKeyWithInk : undefined,
91
120
  });
92
121
 
122
+ // 3. Resolve full Config (API key now present).
93
123
  let config;
94
124
  try {
95
125
  config = await resolveConfig(argv);
96
126
  } catch (e) {
97
- console.error(
98
- error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
99
- );
127
+ if (e instanceof LaunchConfigError) {
128
+ console.error(error(formatLaunchError(e)));
129
+ } else {
130
+ console.error(
131
+ error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
132
+ );
133
+ }
100
134
  process.exit(1);
101
135
  }
102
136
 
@@ -133,7 +167,7 @@ async function main(): Promise<void> {
133
167
  }
134
168
 
135
169
  console.log("");
136
- console.log(" " + infoLine() + " · mood: " + mood);
170
+ console.log(" " + infoLine());
137
171
  console.log("");
138
172
 
139
173
  const { waitUntilExit } = render(
@@ -154,6 +188,7 @@ async function main(): Promise<void> {
154
188
  }
155
189
 
156
190
  // Non-TTY fallback: linear output, readline-based REPL.
191
+ installFatalHandlers();
157
192
  console.log("");
158
193
  if (!skipIntro) {
159
194
  console.log(banner());
@@ -172,6 +207,34 @@ async function main(): Promise<void> {
172
207
  });
173
208
  }
174
209
 
210
+ function formatLaunchError(e: LaunchConfigError): string {
211
+ switch (e.reason) {
212
+ case "model-alias":
213
+ return `Bad model alias: ${e.message}`;
214
+ case "persona-path":
215
+ return `Bad persona file: ${e.message}`;
216
+ case "config-unreadable":
217
+ return `Config file unreadable: ${e.message}`;
218
+ case "api-key-empty":
219
+ return `API key required: ${e.message}`;
220
+ }
221
+ }
222
+
223
+ // Fatal handlers are installed only in the non-TTY path; the interactive
224
+ // path lets Ink's signal-exit hooks run cleanup so the alt-screen restores.
225
+ function installFatalHandlers(): void {
226
+ process.on("unhandledRejection", (reason) => {
227
+ const msg =
228
+ reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
229
+ console.error(error("Unhandled rejection:"), msg);
230
+ process.exitCode = 1;
231
+ });
232
+ process.on("uncaughtException", (err) => {
233
+ console.error(error("Uncaught exception:"), err.stack ?? err.message);
234
+ process.exitCode = 1;
235
+ });
236
+ }
237
+
175
238
  main().catch((e) => {
176
239
  console.error(error("Fatal:"), e);
177
240
  process.exit(1);
@@ -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