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 +1 -1
- package/src/commands.ts +18 -3
- package/src/config.ts +37 -16
- package/src/llm.ts +44 -3
- package/src/pet/petState.ts +10 -0
- package/src/repl.ts +8 -3
- package/src/ui/CommandPalette.tsx +10 -1
- package/src/ui/PetPanel.tsx +30 -24
package/package.json
CHANGED
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
package/src/pet/petState.ts
CHANGED
|
@@ -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,
|
|
73
|
-
|
|
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 }> = {
|
package/src/ui/PetPanel.tsx
CHANGED
|
@@ -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
|
-
?
|
|
240
|
+
? 17
|
|
230
241
|
: Math.min(30, Math.max(20, Math.floor(width * 0.32)));
|
|
231
242
|
const boardWidth = compact
|
|
232
|
-
? Math.min(
|
|
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 >=
|
|
270
|
+
if (gapWidth >= 5) {
|
|
260
271
|
const clockX = windowRight + Math.floor((gapWidth - 5) / 2);
|
|
261
|
-
|
|
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
|
-
|
|
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 +
|
|
322
|
-
rows[R_MASCOT_START +
|
|
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],
|
|
402
|
-
rows[R_MASCOT_START + 6] = place(rows[R_MASCOT_START + 6],
|
|
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 =
|
|
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("
|
|
444
|
+
fitDisplayText("· · · · · · · · · · · · · · · · ·", width),
|
|
439
445
|
);
|
|
440
446
|
}
|
|
441
447
|
|