drexler 0.1.1 → 0.2.0

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/config.ts CHANGED
@@ -11,8 +11,8 @@ import {
11
11
  import { homedir } from "node:os";
12
12
  import { join, resolve } from "node:path";
13
13
  import * as readline from "node:readline/promises";
14
- import type { CliFlags, Config } from "./types.ts";
15
- import { MODEL_FALLBACK, MODEL_PRIMARY } from "./types.ts";
14
+ import type { CliFlags, Config, ThemeName } from "./types.ts";
15
+ import { MODEL_FALLBACK, MODEL_PRIMARY, THEME_NAMES } from "./types.ts";
16
16
 
17
17
  const DEFAULT_MAX_HISTORY = 50;
18
18
 
@@ -47,6 +47,10 @@ export function parseFlags(argv: string[]): CliFlags {
47
47
  flags.persona = argv[++i];
48
48
  } else if (a === "--theme" && valueAfter(i) !== undefined) {
49
49
  flags.theme = argv[++i];
50
+ } else if (a === "--no-intro") {
51
+ flags.noIntro = true;
52
+ } else if (a === "--fast") {
53
+ flags.fast = true;
50
54
  } else if (a !== undefined && a.startsWith("--model=")) {
51
55
  flags.model = a.slice("--model=".length);
52
56
  } else if (a !== undefined && a.startsWith("--persona=")) {
@@ -132,6 +136,25 @@ export function isValidApiKey(k: string | undefined | null): k is string {
132
136
  return true;
133
137
  }
134
138
 
139
+ function parseOptionalBoolean(value: unknown): boolean | undefined {
140
+ if (typeof value === "boolean") return value;
141
+ if (typeof value !== "string") return undefined;
142
+ switch (value.trim().toLowerCase()) {
143
+ case "1":
144
+ case "true":
145
+ case "yes":
146
+ case "on":
147
+ return true;
148
+ case "0":
149
+ case "false":
150
+ case "no":
151
+ case "off":
152
+ return false;
153
+ default:
154
+ return undefined;
155
+ }
156
+ }
157
+
135
158
  async function readApiKeyFromStdin(): Promise<string | null> {
136
159
  const rl = readline.createInterface({
137
160
  input: process.stdin,
@@ -146,16 +169,20 @@ async function readApiKeyFromStdin(): Promise<string | null> {
146
169
  }
147
170
  }
148
171
 
149
- export async function ensureApiKey(): Promise<string> {
172
+ export async function ensureApiKey(opts?: {
173
+ prompt?: () => Promise<string | null>;
174
+ }): Promise<string> {
150
175
  const fileCfg = await loadConfigFile();
151
176
  const envKey = process.env.OPENROUTER_API_KEY;
152
177
  if (isValidApiKey(envKey)) return envKey.trim();
153
178
  if (isValidApiKey(fileCfg.apiKey)) return fileCfg.apiKey.trim();
154
179
 
155
- console.log("Drexler notice no API key on file. Even CEO need credentials.");
156
- console.log("Get free key at: https://openrouter.ai/keys");
180
+ if (!opts?.prompt) {
181
+ console.log("Drexler notice no API key on file. Even CEO need credentials.");
182
+ console.log("Get free key at: https://openrouter.ai/keys");
183
+ }
157
184
 
158
- const entered = await readApiKeyFromStdin();
185
+ const entered = await (opts?.prompt ?? readApiKeyFromStdin)();
159
186
  if (!isValidApiKey(entered)) {
160
187
  console.error("No valid API key provided. Drexler refuse to work pro bono.");
161
188
  process.exit(1);
@@ -212,11 +239,19 @@ export async function resolveConfig(argv: string[]): Promise<Config> {
212
239
  const themeCandidate =
213
240
  flags.theme ?? process.env.DREXLER_THEME ?? fileCfg.theme;
214
241
  const theme =
215
- themeCandidate === "apollo" ||
216
- themeCandidate === "amber" ||
217
- themeCandidate === "mono"
218
- ? themeCandidate
242
+ typeof themeCandidate === "string" &&
243
+ THEME_NAMES.includes(themeCandidate as ThemeName)
244
+ ? (themeCandidate as ThemeName)
219
245
  : undefined;
220
246
 
221
- return { apiKey, model, maxHistory, personaPath, theme };
247
+ const noIntro =
248
+ flags.noIntro ??
249
+ parseOptionalBoolean(process.env.DREXLER_NO_INTRO) ??
250
+ parseOptionalBoolean(fileCfg.noIntro);
251
+ const fast =
252
+ flags.fast ??
253
+ parseOptionalBoolean(process.env.DREXLER_FAST) ??
254
+ parseOptionalBoolean(fileCfg.fast);
255
+
256
+ return { apiKey, model, maxHistory, personaPath, theme, noIntro, fast };
222
257
  }
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import { startRepl } from "./repl.ts";
21
21
  import { App } from "./ui/App.tsx";
22
22
  import { MascotIntro } from "./ui/MascotIntro.tsx";
23
+ import { promptForApiKeyWithInk } from "./ui/SetupPrompt.tsx";
23
24
  import { ThemeProvider } from "./ui/ThemeContext.tsx";
24
25
  import { getActiveTheme, selectTheme, setActiveTheme } from "./ui/themes.ts";
25
26
 
@@ -41,7 +42,9 @@ Usage: drexler [options]
41
42
  Options:
42
43
  --model <31b|26b|id> model alias or full OpenRouter id
43
44
  --persona <path> custom persona markdown
44
- --theme <apollo|amber|mono> color theme (default apollo)
45
+ --theme <name> color theme (default apollo)
46
+ --no-intro skip startup banner and mascot
47
+ --fast fast startup mode, implies --no-intro
45
48
  --version, -v print version
46
49
  --help, -h this help
47
50
 
@@ -51,9 +54,18 @@ Slash commands inside REPL:
51
54
  /exit exit
52
55
  /synergy SYNERGY!
53
56
  /model [id] show or switch model
57
+ /theme [name] show or switch theme; append save to persist
58
+ /startup [mode] persist startup mode: fast, no-intro, normal
54
59
  /history message + token count
55
60
  /regenerate re-roll last response
61
+ /retry [style] re-roll last response as terse or brutal
62
+ /expand print latest response
63
+ /quote quote latest response
64
+ /search <term> search transcript
65
+ /export <fmt> [path] export md, txt, json, or html
56
66
  /save [path] archive conversation as markdown
67
+ /save-last [path] save latest response
68
+ /copy-last copy latest response to clipboard
57
69
 
58
70
  Ctrl+C exits gracefully.`;
59
71
 
@@ -70,8 +82,13 @@ async function main(): Promise<void> {
70
82
  process.exit(0);
71
83
  }
72
84
 
85
+ const isInteractive =
86
+ process.stdout.isTTY === true && process.stdin.isTTY === true;
87
+
73
88
  // Acquire API key. Prompts interactively if missing — runs BEFORE banner.
74
- await ensureApiKey();
89
+ await ensureApiKey({
90
+ prompt: isInteractive ? promptForApiKeyWithInk : undefined,
91
+ });
75
92
 
76
93
  let config;
77
94
  try {
@@ -105,27 +122,27 @@ async function main(): Promise<void> {
105
122
  systemPromptWithMood,
106
123
  config.maxHistory,
107
124
  );
108
-
109
- const isInteractive =
110
- process.stdout.isTTY === true && process.stdin.isTTY === true;
125
+ const skipIntro = config.noIntro === true || config.fast === true;
111
126
 
112
127
  if (isInteractive) {
113
- // Print intro to stdout before Ink mounts. Ink's <Static> can't host
114
- // animated state, and we want the banner visible from boot.
115
- console.log("");
116
- await typewriterBanner();
117
- console.log(tagline());
118
- console.log("");
119
- // Animated welcome card via transient Ink instance.
120
- const intro = render(
121
- React.createElement(ThemeProvider, {
122
- value: getActiveTheme(),
123
- children: React.createElement(MascotIntro, { greeting }),
124
- }),
125
- { exitOnCtrlC: false },
126
- );
127
- await intro.waitUntilExit();
128
- intro.unmount();
128
+ if (!skipIntro) {
129
+ // Print intro to stdout before Ink mounts. Ink's <Static> can't host
130
+ // animated state, and we want the banner visible from boot.
131
+ console.log("");
132
+ await typewriterBanner();
133
+ console.log(tagline());
134
+ console.log("");
135
+ // Animated welcome card via transient Ink instance.
136
+ const intro = render(
137
+ React.createElement(ThemeProvider, {
138
+ value: getActiveTheme(),
139
+ children: React.createElement(MascotIntro, { greeting }),
140
+ }),
141
+ { exitOnCtrlC: false },
142
+ );
143
+ await intro.waitUntilExit();
144
+ intro.unmount();
145
+ }
129
146
 
130
147
  console.log("");
131
148
  console.log(" " + infoLine() + " · mood: " + mood);
@@ -134,7 +151,7 @@ async function main(): Promise<void> {
134
151
  const { waitUntilExit } = render(
135
152
  React.createElement(ThemeProvider, {
136
153
  value: getActiveTheme(),
137
- children: React.createElement(App, { conversation, config }),
154
+ children: React.createElement(App, { conversation, config, mood }),
138
155
  }),
139
156
  { exitOnCtrlC: false },
140
157
  );
@@ -144,11 +161,13 @@ async function main(): Promise<void> {
144
161
 
145
162
  // Non-TTY fallback: linear output, readline-based REPL.
146
163
  console.log("");
147
- console.log(banner());
148
- console.log(tagline());
149
- console.log("");
150
- console.log(welcomeBox(greeting, termCols()));
151
- console.log("");
164
+ if (!skipIntro) {
165
+ console.log(banner());
166
+ console.log(tagline());
167
+ console.log("");
168
+ console.log(welcomeBox(greeting, termCols()));
169
+ console.log("");
170
+ }
152
171
  console.log(" " + infoLine() + " · mood: " + mood);
153
172
  console.log("");
154
173
 
package/src/renderer.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import chalk from "chalk";
2
- import { highlight } from "cli-highlight";
3
2
  import { marked } from "marked";
4
3
  import { markedTerminal } from "marked-terminal";
5
4
  import { THINKING_LINES, WITTICISMS } from "./sayings.ts";
@@ -10,13 +9,26 @@ export function getColors() {
10
9
  return buildChalkColors(getActiveTheme());
11
10
  }
12
11
 
12
+ // cli-highlight is ~160KB and only used for code blocks inside markdown.
13
+ // Pre-warm in background so cold boot doesn't pay parse cost on critical path;
14
+ // fall back to dim raw text on the off-chance a code block renders pre-load.
15
+ type HighlightFn = (code: string, opts: { language?: string; ignoreIllegals?: boolean }) => string;
16
+ let highlightFn: HighlightFn | null = null;
17
+ void import("cli-highlight").then((m) => {
18
+ highlightFn = m.highlight as HighlightFn;
19
+ });
20
+
13
21
  function highlightCodeBlock(code: string, lang: string | undefined): string {
14
22
  let body: string;
15
- try {
16
- body = lang
17
- ? highlight(code, { language: lang, ignoreIllegals: true })
18
- : highlight(code, { ignoreIllegals: true });
19
- } catch {
23
+ if (highlightFn) {
24
+ try {
25
+ body = lang
26
+ ? highlightFn(code, { language: lang, ignoreIllegals: true })
27
+ : highlightFn(code, { ignoreIllegals: true });
28
+ } catch {
29
+ body = chalk.gray(code);
30
+ }
31
+ } else {
20
32
  body = chalk.gray(code);
21
33
  }
22
34
  const c = getColors();
@@ -327,18 +339,10 @@ export function error(msg: string): string {
327
339
  return getColors().error(msg);
328
340
  }
329
341
 
330
- export function warning(msg: string): string {
331
- return getColors().warning(msg);
332
- }
333
-
334
342
  export function dim(msg: string): string {
335
343
  return getColors().dim(msg);
336
344
  }
337
345
 
338
- export function separator(): string {
339
- return getColors().apolloDim("─".repeat(BOX_WIDTH));
340
- }
341
-
342
346
  export function pickThinkingLine(): string {
343
347
  return THINKING_LINES[Math.floor(Math.random() * THINKING_LINES.length)] ?? "Drexler thinking";
344
348
  }
package/src/repl.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  isSlash,
6
6
  type CommandAction,
7
7
  } from "./commands.ts";
8
+ import { saveConfig } from "./config.ts";
8
9
  import type { Conversation } from "./conversation.ts";
9
10
  import { streamChat, type FetchFn } from "./llm.ts";
10
11
  import type { Message } from "./types.ts";
@@ -40,11 +41,25 @@ export interface ReplDeps {
40
41
  print: (s: string) => void;
41
42
  }
42
43
 
43
- function pickFallback(currentModel: string): string {
44
+ async function persistPreferences(
45
+ partial: Partial<Config> | undefined,
46
+ print: (s: string) => void,
47
+ ): Promise<void> {
48
+ if (!partial) return;
49
+ try {
50
+ await saveConfig(partial);
51
+ print(info("Drexler preferences filed."));
52
+ } catch (e) {
53
+ const msg = e instanceof Error ? e.message : String(e);
54
+ print(error(`Could not save preferences: ${msg}`));
55
+ }
56
+ }
57
+
58
+ export function pickFallback(currentModel: string): string {
44
59
  return currentModel === MODEL_PRIMARY ? MODEL_FALLBACK : MODEL_PRIMARY;
45
60
  }
46
61
 
47
- function buildMessagesWithReminder(conv: Conversation): Message[] {
62
+ export function buildMessagesWithReminder(conv: Conversation): Message[] {
48
63
  const snap = conv.snapshot();
49
64
  const turns = conv.userTurns;
50
65
  if (turns > 0 && turns % REMINDER_INTERVAL === 0) {
@@ -70,7 +85,10 @@ interface KeypressKey {
70
85
  }
71
86
  type KeypressListener = (str: string | undefined, key: KeypressKey) => void;
72
87
 
73
- async function streamFromHistory(deps: ReplDeps): Promise<void> {
88
+ async function streamFromHistory(
89
+ deps: ReplDeps,
90
+ instruction?: string,
91
+ ): Promise<void> {
74
92
  const spinner = startSpinner();
75
93
  let firstToken = true;
76
94
  const accent = createAccentBarWriter();
@@ -102,7 +120,12 @@ async function streamFromHistory(deps: ReplDeps): Promise<void> {
102
120
  apiKey: deps.config.apiKey,
103
121
  model: deps.config.model,
104
122
  fallbackModel: pickFallback(deps.config.model),
105
- messages: buildMessagesWithReminder(deps.conversation),
123
+ messages: instruction
124
+ ? [
125
+ ...buildMessagesWithReminder(deps.conversation),
126
+ { role: "system", content: instruction },
127
+ ]
128
+ : buildMessagesWithReminder(deps.conversation),
106
129
  onToken,
107
130
  signal: abort.signal,
108
131
  fetchFn: deps.fetchFn,
@@ -151,8 +174,11 @@ export async function handleLine(
151
174
  config: deps.config,
152
175
  print: deps.print,
153
176
  });
177
+ if (action.type === "continue") {
178
+ await persistPreferences(action.persistConfig, deps.print);
179
+ }
154
180
  if (action.type === "regenerate") {
155
- await streamFromHistory(deps);
181
+ await streamFromHistory(deps, action.instruction);
156
182
  return { type: "continue" };
157
183
  }
158
184
  return action;
package/src/types.ts CHANGED
@@ -8,12 +8,27 @@ export interface Message {
8
8
  export const MODEL_PRIMARY = "google/gemma-4-31b-it";
9
9
  export const MODEL_FALLBACK = "google/gemma-4-26b-a4b-it";
10
10
 
11
+ export const THEME_NAMES = [
12
+ "apollo",
13
+ "amber",
14
+ "mono",
15
+ "terminal",
16
+ "dealroom",
17
+ "midnight",
18
+ "paper",
19
+ "plasma",
20
+ ] as const;
21
+
22
+ export type ThemeName = (typeof THEME_NAMES)[number];
23
+
11
24
  export interface Config {
12
25
  apiKey: string;
13
26
  model: string;
14
27
  maxHistory: number;
15
28
  personaPath: string;
16
- theme?: string;
29
+ theme?: ThemeName;
30
+ noIntro?: boolean;
31
+ fast?: boolean;
17
32
  }
18
33
 
19
34
  export interface PersonaData {
@@ -25,6 +40,8 @@ export interface CliFlags {
25
40
  model?: string;
26
41
  persona?: string;
27
42
  theme?: string;
43
+ noIntro?: boolean;
44
+ fast?: boolean;
28
45
  }
29
46
 
30
47
  export interface OpenRouterRequestBody {