drexler 0.2.21 → 0.2.22

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "drexler",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "CLI chat with Drexler, a corporate-executive AI persona built on OpenRouter Gemma 4 31B.",
5
5
  "license": "MIT",
6
6
  "author": "showOS",
package/src/commands.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { existsSync, writeFileSync } from "node:fs";
3
- import { resolve as pathResolve } from "node:path";
3
+ import { isAbsolute, resolve as pathResolve, sep as pathSep } from "node:path";
4
4
  import {
5
5
  getConfigPath,
6
6
  getDrexlerVersion,
@@ -499,9 +499,24 @@ function resolveWriteTarget(
499
499
  ctx.print(error(`Invalid path: ${pathArg} (no '..' segments allowed).`));
500
500
  return null;
501
501
  }
502
+ const cwd = process.cwd();
502
503
  const target = pathArg
503
- ? pathResolve(pathArg)
504
- : pathResolve(`${defaultPrefix}-${Date.now()}${requiredExt}`);
504
+ ? pathResolve(cwd, pathArg)
505
+ : pathResolve(cwd, `${defaultPrefix}-${Date.now()}${requiredExt}`);
506
+ // Sandbox relative paths to cwd. Absolute paths are user-explicit
507
+ // (e.g. `/save /tmp/notes.md`) and trusted — drexler runs with the
508
+ // user's own permissions, so they can write anywhere they could write
509
+ // from the shell. Defense in depth catches relative paths that, after
510
+ // resolution, would land outside cwd via a sneaky construction.
511
+ if (pathArg && !isAbsolute(pathArg)) {
512
+ const cwdPrefix = cwd.endsWith(pathSep) ? cwd : cwd + pathSep;
513
+ if (target !== cwd && !target.startsWith(cwdPrefix)) {
514
+ ctx.print(
515
+ error(`Invalid path: ${pathArg} (resolves outside current directory).`),
516
+ );
517
+ return null;
518
+ }
519
+ }
505
520
  if (!target.toLowerCase().endsWith(requiredExt)) {
506
521
  ctx.print(error(`Target must end in ${requiredExt}: ${target}`));
507
522
  return null;
package/src/config.ts CHANGED
@@ -151,28 +151,49 @@ async function validatePersonaFile(
151
151
  return resolved;
152
152
  }
153
153
 
154
- export async function loadConfigFile(): Promise<Partial<Config>> {
155
- const cp = configPath();
156
- const lp = legacyConfigPath();
157
- const path = existsSync(cp) ? cp : existsSync(lp) ? lp : null;
158
- if (!path) return {};
154
+ async function readConfigPath(
155
+ path: string,
156
+ ): Promise<{ raw: string; missing: false } | { raw: null; missing: true } | { raw: null; missing: false; error: NodeJS.ErrnoException }> {
159
157
  try {
160
- const raw = await readFile(path, "utf-8");
161
- const parsed: unknown = JSON.parse(raw);
162
- if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
158
+ return { raw: await readFile(path, "utf-8"), missing: false };
159
+ } catch (err) {
160
+ const code = (err as NodeJS.ErrnoException).code;
161
+ if (code === "ENOENT") return { raw: null, missing: true };
162
+ return { raw: null, missing: false, error: err as NodeJS.ErrnoException };
163
+ }
164
+ }
165
+
166
+ export async function loadConfigFile(): Promise<Partial<Config>> {
167
+ // Try canonical XDG path first, then legacy ~/.drexlerrc. Reading
168
+ // unconditionally (instead of gating on existsSync) means EACCES
169
+ // surfaces a warning rather than silently masquerading as "no file".
170
+ for (const path of [configPath(), legacyConfigPath()]) {
171
+ const result = await readConfigPath(path);
172
+ if (result.missing) continue;
173
+ if (result.raw === null) {
163
174
  console.warn(
164
- `Drexler config at ${path} is not a JSON object; ignoring (defaults applied).`,
175
+ `Drexler config at ${path} could not be read (${result.error.code ?? result.error.message}); ignoring (defaults applied).`,
176
+ );
177
+ return {};
178
+ }
179
+ try {
180
+ const parsed: unknown = JSON.parse(result.raw);
181
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
182
+ console.warn(
183
+ `Drexler config at ${path} is not a JSON object; ignoring (defaults applied).`,
184
+ );
185
+ return {};
186
+ }
187
+ return parsed as Partial<Config>;
188
+ } catch (err) {
189
+ const msg = err instanceof Error ? err.message : String(err);
190
+ console.warn(
191
+ `Drexler config at ${path} could not be parsed (${msg}); ignoring (defaults applied).`,
165
192
  );
166
193
  return {};
167
194
  }
168
- return parsed as Partial<Config>;
169
- } catch (err) {
170
- const msg = err instanceof Error ? err.message : String(err);
171
- console.warn(
172
- `Drexler config at ${path} could not be read (${msg}); ignoring (defaults applied).`,
173
- );
174
- return {};
175
195
  }
196
+ return {};
176
197
  }
177
198
 
178
199
  export async function saveConfig(partial: Partial<Config>): Promise<void> {
package/src/llm.ts CHANGED
@@ -11,6 +11,32 @@ const STOP_SEQUENCES = [
11
11
  ];
12
12
  const RETRY_DELAY_MS = 250;
13
13
 
14
+ function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> {
15
+ return new Promise<void>((resolve, reject) => {
16
+ if (signal?.aborted) {
17
+ reject(new Error("aborted before retry"));
18
+ return;
19
+ }
20
+ const timer = setTimeout(() => {
21
+ signal?.removeEventListener("abort", onAbort);
22
+ resolve();
23
+ }, ms);
24
+ const onAbort = () => {
25
+ clearTimeout(timer);
26
+ reject(new Error("aborted before retry"));
27
+ };
28
+ signal?.addEventListener("abort", onAbort, { once: true });
29
+ });
30
+ }
31
+
32
+ function debugWarn(label: string, detail: string): void {
33
+ if (process.env.DREXLER_DEBUG && process.env.DREXLER_DEBUG !== "0") {
34
+ try {
35
+ process.stderr.write(`[drexler ${label}] ${detail}\n`);
36
+ } catch {}
37
+ }
38
+ }
39
+
14
40
  export type FetchFn = (
15
41
  url: string | URL | Request,
16
42
  init?: RequestInit,
@@ -112,7 +138,18 @@ async function attempt(
112
138
  try {
113
139
  await res.text();
114
140
  } catch {}
115
- await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
141
+ // Retry delay must be abortable — if the user hits Esc during the
142
+ // wait, we should bail immediately instead of firing the second
143
+ // request only to cancel it once issued.
144
+ try {
145
+ await abortableDelay(RETRY_DELAY_MS, opts.signal);
146
+ } catch (err) {
147
+ return {
148
+ status: "http_error",
149
+ content: "",
150
+ error: err instanceof Error ? err.message : String(err),
151
+ };
152
+ }
116
153
  return attempt(model, opts, fetchFn, true);
117
154
  }
118
155
 
@@ -189,8 +226,12 @@ export async function parseSSEStream(
189
226
  acc += tok;
190
227
  onToken(tok);
191
228
  }
192
- } catch {
193
- // tolerate malformed chunk
229
+ } catch (err) {
230
+ // Tolerate malformed chunks — OpenRouter occasionally emits
231
+ // partial JSON during slow connections. Visible only when
232
+ // DREXLER_DEBUG is set so production stays quiet.
233
+ const msg = err instanceof Error ? err.message : String(err);
234
+ debugWarn("sse parse", `${msg}: ${data.slice(0, 80)}`);
194
235
  }
195
236
  };
196
237
 
@@ -33,10 +33,16 @@ export interface PetStats {
33
33
 
34
34
  const MAX_NAME_LEN = 16;
35
35
  const NAME_SANITIZE_RE = /[^\p{L}\p{N} ._'-]/gu;
36
+ // Strip ALL Unicode format/control marks (bidi overrides, ZWJ/ZWNJ,
37
+ // BOM, word joiner, mathematical invisibles) before character-class
38
+ // filtering — otherwise a name like "Max" can render as "xaM" via an
39
+ // embedded U+202E RIGHT-TO-LEFT OVERRIDE that survives NFKC.
40
+ const NAME_BIDI_STRIP_RE = /\p{Cf}/gu;
36
41
 
37
42
  export function sanitizePetName(input: string): string {
38
43
  const cleaned = input
39
44
  .normalize("NFKC")
45
+ .replace(NAME_BIDI_STRIP_RE, "")
40
46
  .replace(NAME_SANITIZE_RE, "")
41
47
  .replace(/\s+/g, " ")
42
48
  .trim();
@@ -76,6 +82,10 @@ export function actionCooldown(
76
82
  return { ok: true, remainingMs: 0 };
77
83
  }
78
84
  const elapsed = now - last;
85
+ // Clock skew backwards (timestamp set in the future) shouldn't lock
86
+ // the user out for the cooldown window — treat as no cooldown and
87
+ // let the next stampAction overwrite the stale future value.
88
+ if (elapsed < 0) return { ok: true, remainingMs: 0 };
79
89
  if (elapsed >= PET_COOLDOWN_MS) return { ok: true, remainingMs: 0 };
80
90
  return { ok: false, remainingMs: PET_COOLDOWN_MS - elapsed };
81
91
  }
package/src/repl.ts CHANGED
@@ -69,13 +69,18 @@ export function buildMessagesWithReminder(conv: Conversation): Message[] {
69
69
  }
70
70
 
71
71
  // Confusable letters that look like Latin "I" — fold to ASCII before regex
72
- // so detection isn't bypassed by Cyrillic І, Turkish İ, fullwidth I, etc.
73
- const I_CONFUSABLES_RE = /[ІіİıIℐⅠ]/g;
72
+ // so detection isn't bypassed by Cyrillic І, Turkish İ, fullwidth I,
73
+ // Greek Iota Ι/ι, script ℐ, Roman numeral Ⅰ.
74
+ const I_CONFUSABLES_RE = /[ІіİıIℐⅠΙι]/g;
74
75
 
75
76
  export function detectPersonaDrift(content: string): boolean {
76
77
  const noCode = content
77
78
  .replace(/```[\s\S]*?```/g, "")
78
- .replace(/`[^`]*`/g, "");
79
+ .replace(/`[^`]*`/g, "")
80
+ // Strip LaTeX-style inline math $...$ and display math $$...$$ so
81
+ // `$I = mc^2$` doesn't trip drift detection.
82
+ .replace(/\$\$[\s\S]*?\$\$/g, "")
83
+ .replace(/\$[^\$\n]*\$/g, "");
79
84
  const folded = noCode.normalize("NFKC").replace(I_CONFUSABLES_RE, "I");
80
85
  return /\bI\b|\bI'm\b|\bI'll\b|\bI've\b|\bI'd\b/.test(folded);
81
86
  }
@@ -10,7 +10,7 @@ interface Props {
10
10
  width?: number;
11
11
  }
12
12
 
13
- const COMMAND_HINTS: Record<string, string> = {
13
+ export const COMMAND_HINTS: Record<string, string> = {
14
14
  "/help": "open directive list",
15
15
  "/clear": "reset transcript",
16
16
  "/exit": "close session",
@@ -31,6 +31,15 @@ const COMMAND_HINTS: Record<string, string> = {
31
31
  "/copy-last": "copy latest response",
32
32
  "/setup": "show config + key source",
33
33
  "/update": "bun update -g drexler --latest",
34
+ "/pet": "open pet deal desk",
35
+ "/feed": "feed Drexler a deal memo",
36
+ "/play": "corporate synergy game",
37
+ "/work": "grind the deal pipeline",
38
+ "/praise": "affirm Drexler's contributions",
39
+ "/rest": "strategic nap",
40
+ "/vibe": "let Drexler pick the move",
41
+ "/name": "/name Bartholomew",
42
+ "/profile": "print personnel file",
34
43
  };
35
44
 
36
45
  const ARGUMENT_TITLES: Record<string, { title: string; hint: string }> = {
@@ -120,6 +120,17 @@ function cupForEnergy(energy: number): string {
120
120
  return "c_";
121
121
  }
122
122
 
123
+ function analogClockSprite(frame: number): readonly string[] {
124
+ const hands = [
125
+ ["│╲│ │", "│ └─│"],
126
+ ["│ │╱│", "│─┘ │"],
127
+ ["│ │ │", "│─┼─│"],
128
+ ["│╲│ │", "│─┘ │"],
129
+ ] as const;
130
+ const [upper, lower] = hands[Math.floor(frame / 2) % hands.length] ?? hands[0]!;
131
+ return ["╭───╮", upper, lower, "╰───╯"];
132
+ }
133
+
123
134
  function progressTicker(frame: number): string {
124
135
  const dots = ".............";
125
136
  const idx = frame % dots.length;
@@ -226,10 +237,10 @@ function drawOfficeBackground(rows: string[], width: number, frame: number, stat
226
237
 
227
238
  const compact = width < 62;
228
239
  const windowWidth = compact
229
- ? 18
240
+ ? 17
230
241
  : Math.min(30, Math.max(20, Math.floor(width * 0.32)));
231
242
  const boardWidth = compact
232
- ? Math.min(26, Math.max(20, width - windowWidth - 5))
243
+ ? Math.min(25, Math.max(20, width - windowWidth - 5))
233
244
  : Math.min(36, Math.max(26, Math.floor(width * 0.36)));
234
245
  const boardX = Math.max(windowWidth + 3, width - boardWidth - 2);
235
246
  const windowRight = 1 + windowWidth;
@@ -256,14 +267,9 @@ function drawOfficeBackground(rows: string[], width: number, frame: number, stat
256
267
  ],
257
268
  );
258
269
 
259
- if (gapWidth >= 7) {
270
+ if (gapWidth >= 5) {
260
271
  const clockX = windowRight + Math.floor((gapWidth - 5) / 2);
261
- const hour = frame % 8 < 4 ? "09" : "10";
262
- placeSprite(rows, R_WINDOW_TOP, clockX, [
263
- "╭──╮",
264
- `│${hour}│`,
265
- "╰──╯",
266
- ]);
272
+ placeSprite(rows, R_WINDOW_TOP, clockX, analogClockSprite(frame));
267
273
  }
268
274
 
269
275
  rows[R_ACTIVITY] = centerText(
@@ -275,12 +281,12 @@ function drawOfficeBackground(rows: string[], width: number, frame: number, stat
275
281
  function drawOfficeFurniture(rows: string[], width: number, frame: number): void {
276
282
  const lampX = 1;
277
283
  const cabinetX = Math.max(1, width - 8);
278
- const shade = frame % 8 < 4 ? "╭░░░░╮" : "╭▒▒▒▒╮";
284
+ const shade = frame % 8 < 4 ? "╱____╲" : "╱ ╲";
279
285
  const plantTop = frame % 6 < 3 ? " ╲│╱ " : " ╱│╲ ";
280
286
 
281
287
  placeSprite(rows, R_MASCOT_START, lampX, [
282
- ` ${shade} `,
283
- " ╱▒▒▒▒╲",
288
+ " ╭──╮ ",
289
+ ` ${shade}`,
284
290
  " ╰─┬──╯",
285
291
  " │ ",
286
292
  " ╭─┴─╮ ",
@@ -290,12 +296,12 @@ function drawOfficeFurniture(rows: string[], width: number, frame: number): void
290
296
 
291
297
  placeSprite(rows, R_MASCOT_START, cabinetX, [
292
298
  plantTop,
293
- " ╲│╱ ",
294
- " ╰┬╯ ",
299
+ "",
300
+ " ╭┴╮ ",
295
301
  "╭FILE╮",
296
302
  "│▤▤▤│",
297
303
  "├────┤",
298
- "│▤▤▤│",
304
+ "╰────╯",
299
305
  ]);
300
306
  }
301
307
 
@@ -318,9 +324,9 @@ function drawActivityAccents(
318
324
 
319
325
  switch (activity) {
320
326
  case "eating":
321
- rows[R_MASCOT_START + 5] = place(
322
- rows[R_MASCOT_START + 5],
323
- "╭$╮",
327
+ rows[R_MASCOT_START + 3] = place(
328
+ rows[R_MASCOT_START + 3],
329
+ "[$]",
324
330
  Math.max(1, Math.min(fileX - 5, rightAccentX + 1)),
325
331
  );
326
332
  break;
@@ -398,8 +404,8 @@ function drawDesktopObjects(
398
404
 
399
405
  if (mugX > mascotRight + 1) {
400
406
  rows[R_MASCOT_START + 4] = place(rows[R_MASCOT_START + 4], steam, mugX + 1);
401
- rows[R_MASCOT_START + 5] = place(rows[R_MASCOT_START + 5], "╭─╮", mugX);
402
- rows[R_MASCOT_START + 6] = place(rows[R_MASCOT_START + 6], `╰${cupForEnergy(stats.energy)}╯`, mugX);
407
+ rows[R_MASCOT_START + 5] = place(rows[R_MASCOT_START + 5], `╭${cupForEnergy(stats.energy)}╮`, mugX);
408
+ rows[R_MASCOT_START + 6] = place(rows[R_MASCOT_START + 6], "╰──╯", mugX);
403
409
  }
404
410
  }
405
411
 
@@ -407,11 +413,11 @@ function drawDesk(rows: string[], width: number, stats: PetStats): void {
407
413
  const deskX = width > PET_SCENE_WIDTH ? 2 : 1;
408
414
  const deskWidth = Math.max(4, width - deskX * 2);
409
415
  const deskInner = Math.max(1, deskWidth - 2);
410
- const surface = `▱▱▱ [${cupForEnergy(stats.energy)}] ▬▬▬▬▬ COV OK`;
416
+ const surface = "▱▱▱ ▬▬▬▬▬ COV OK";
411
417
  const front = `[IN] ║ DREXLER DEAL DESK ║ PIPE ${Math.round(stats.deals)}% ║ [OUT]`;
412
418
  const drawers = width < 68
413
- ? "╭────╮ ╭────╮ ╭────╮"
414
- : "╭────╮ ╭────╮ ╭────╮ ╭────╮";
419
+ ? "│▤▤│ │▤▤│ │▤▤│"
420
+ : "│▤▤│ │▤▤│ │▤▤│ │▤▤│";
415
421
 
416
422
  rows[R_DESK_SURFACE] = place(
417
423
  rows[R_DESK_SURFACE],
@@ -435,7 +441,7 @@ function drawDesk(rows: string[], width: number, stats: PetStats): void {
435
441
  );
436
442
  rows[R_FLOOR] = centerText(
437
443
  rows[R_FLOOR],
438
- fitDisplayText("░░░░░░░ deal-room carpet shadow ░░░░░░░", width),
444
+ fitDisplayText("· · · · · · · · · · · · · · · · ·", width),
439
445
  );
440
446
  }
441
447