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.
- package/CHANGELOG.md +13 -0
- package/README.md +17 -1
- package/package.json +1 -1
- package/src/commands.ts +127 -20
- package/src/config.ts +141 -32
- package/src/conversation.ts +0 -4
- package/src/index.ts +70 -5
- package/src/pet/petState.ts +408 -0
- package/src/repl.ts +1 -1
- package/src/ui/App.tsx +571 -148
- package/src/ui/CommandPalette.tsx +2 -0
- package/src/ui/DealDeskHeader.tsx +0 -5
- package/src/ui/DeathScreen.tsx +110 -0
- package/src/ui/MarkdownBody.tsx +29 -5
- package/src/ui/MascotIntro.tsx +158 -57
- package/src/ui/Message.tsx +2 -105
- package/src/ui/PetPanel.tsx +673 -0
- package/src/ui/TranscriptViewport.tsx +206 -48
- package/src/ui/displayContent.ts +5 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.16
|
|
4
|
+
|
|
5
|
+
- Added an interactive pet system: feed, play, work, praise, rest, vibe, name, and profile commands; persistent stats with offline decay; intern→analyst→associate→VP→MD rank ladder driven by lifetime deal accumulation; 90-second cooldowns per action with in-character rejection copy.
|
|
6
|
+
- Adaptive pet UI: full animated panel on wide terminals (cols ≥ 112), bordered compact panel on medium terminals (≥ 48), one-line ticker surfacing the worst stat on tiny terminals.
|
|
7
|
+
- Compact panel routes stats through the existing satirical level ladder (peak/good/ok/low/critical) instead of bare percentages so it matches the Deal Desk surface.
|
|
8
|
+
- Pet save is now atomic (temp file + rename); dead-pet command guard prevents stat mutation during the death exit timer; frame interval pauses when the pet has died.
|
|
9
|
+
- Hardened launch flow: validate CLI flags and config before the first-run API key prompt with reason-specific errors. Fatal handlers moved off the interactive path so Ink's signal-exit can restore the terminal cleanly.
|
|
10
|
+
- Markdown link parser now balances parentheses, so URLs like `https://en.wikipedia.org/wiki/Foo_(bar)` parse correctly.
|
|
11
|
+
- New informational commands: `/setup` prints config + API key source without leaking the key; `/update` prints upgrade instructions and refuses to run installs.
|
|
12
|
+
- Transcript viewport enforces a hard row budget — oversized cards clip with an explicit `... N lines truncated — PageUp scrollback to read` hint; indicators report row counts in addition to item counts; scrollback keys work while a response is streaming.
|
|
13
|
+
- Command palette Enter on bare argument-parent commands (`/theme`, `/model`, `/startup`, `/retry`, `/export`) now reopens the chooser instead of executing the base form; history navigation preserves the unsent draft.
|
|
14
|
+
- Performance: collapsed duplicate width memos, hoisted divider/carpet constants, memoized StatBar, tightened the pet panel frame loop.
|
|
15
|
+
|
|
3
16
|
## 0.2.14
|
|
4
17
|
|
|
5
18
|
- Added a startup Mood panel with a stable boot gauge, percentage-only loading row, and rotating mood-specific posture/detail copy.
|
package/README.md
CHANGED
|
@@ -64,11 +64,17 @@ Paste the key, hit return. Drexler saves it to `~/.config/drexler/config.json` (
|
|
|
64
64
|
|
|
65
65
|
## Update
|
|
66
66
|
|
|
67
|
+
```bash
|
|
68
|
+
bun update -g drexler --latest
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If your Bun install does not have the package in its global lockfile, reinstall it instead:
|
|
72
|
+
|
|
67
73
|
```bash
|
|
68
74
|
bun install -g drexler@latest
|
|
69
75
|
```
|
|
70
76
|
|
|
71
|
-
Global
|
|
77
|
+
Global updates replace the existing `drexler` package in Bun's global install location; they do not keep stacking duplicate app copies.
|
|
72
78
|
|
|
73
79
|
## Uninstall
|
|
74
80
|
|
|
@@ -85,6 +91,8 @@ rm -rf ~/.config/drexler # optional: wipe stored key + settings
|
|
|
85
91
|
|
|
86
92
|
Drexler runs as an Ink terminal UI when both stdin and stdout are TTYs. The normal launch shows one integrated startup panel with the mascot, tips, a **Mood** readout, and the **Drexler Deal Desk**. Short terminals automatically suppress oversized startup chrome so the chat stays usable.
|
|
87
93
|
|
|
94
|
+
After startup, the pet UI adapts to the terminal instead of disappearing. Wide terminals show the animated **Drexler Pet Desk** as a right-side panel. Medium terminals show a compact pet panel below the Deal Desk. Tiny terminals show a one-line pet ticker so pet status remains visible without crushing the chat.
|
|
95
|
+
|
|
88
96
|
The startup panel is designed to stay stable while it boots: the mascot loading bar and Mood gauge animate without changing width, greeting copy is held in a fixed slot, and the Mood and Deal Desk boxes stay aligned when the greeting wraps. After boot, Mood resolves into a rotating Drexler-flavored posture with a short satirical subtext line.
|
|
89
97
|
|
|
90
98
|
The Deal Desk is intentionally not a frontier-model telemetry panel. It shows mood-shaped corporate nonsense like boardroom status, memo count, mandate, risk, fees, and counsel posture. Values rotate by mood and session so repeated moods still feel alive.
|
|
@@ -128,6 +136,14 @@ Keyboard notes:
|
|
|
128
136
|
| `/clear` | shred conversation history (system prompt pinned) |
|
|
129
137
|
| `/exit` | meeting adjourned |
|
|
130
138
|
| `/synergy` | run a rotating animated morale event |
|
|
139
|
+
| `/feed` | feed Drexler a deal memo |
|
|
140
|
+
| `/play` | corporate synergy game with Drexler |
|
|
141
|
+
| `/work` | Drexler grinds the deal pipeline |
|
|
142
|
+
| `/praise` | affirm Drexler's contributions |
|
|
143
|
+
| `/rest` | strategic nap |
|
|
144
|
+
| `/vibe` | let Drexler choose his own adventure |
|
|
145
|
+
| `/name [name]` | view or assign Drexler's pet name |
|
|
146
|
+
| `/profile` | print Drexler's personnel file |
|
|
131
147
|
| `/model` | show current model, or `/model 26b` to switch |
|
|
132
148
|
| `/theme` | show/switch theme; append `save` to persist, e.g. `/theme midnight save` |
|
|
133
149
|
| `/startup fast\|no-intro\|normal` | persist startup behavior for future launches |
|
package/package.json
CHANGED
package/src/commands.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { resolve as pathResolve } from "node:path";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
getConfigPath,
|
|
6
|
+
getDrexlerVersion,
|
|
7
|
+
getResolvedConfigPath,
|
|
8
|
+
isValidApiKey,
|
|
9
|
+
resolveModel,
|
|
10
|
+
} from "./config.ts";
|
|
5
11
|
import type { Conversation } from "./conversation.ts";
|
|
6
12
|
import { error, resetMarkedTheme } from "./renderer.ts";
|
|
7
13
|
import { THEME_NAMES, type Config, type ThemeName } from "./types.ts";
|
|
@@ -17,6 +23,14 @@ export type CommandAction =
|
|
|
17
23
|
| { type: "exit"; message?: string }
|
|
18
24
|
| { type: "regenerate"; instruction?: string; removedAssistant: boolean };
|
|
19
25
|
|
|
26
|
+
export type CommandGroup =
|
|
27
|
+
| "directives"
|
|
28
|
+
| "themes"
|
|
29
|
+
| "models"
|
|
30
|
+
| "startup"
|
|
31
|
+
| "retry"
|
|
32
|
+
| "export";
|
|
33
|
+
|
|
20
34
|
interface CommandContext {
|
|
21
35
|
conversation: Conversation;
|
|
22
36
|
config: Config;
|
|
@@ -33,6 +47,14 @@ const HELP_TEXT = `New memo to staff! Drexler permit following directives:
|
|
|
33
47
|
/clear - shred all documents (reset history)
|
|
34
48
|
/exit - meeting adjourned
|
|
35
49
|
/synergy - SYNERGY!
|
|
50
|
+
/feed - feed Drexler a deal memo
|
|
51
|
+
/play - corporate synergy game with Drexler
|
|
52
|
+
/work - Drexler grinds the deal pipeline
|
|
53
|
+
/praise - affirm Drexler's contributions
|
|
54
|
+
/rest - Drexler takes a strategic nap
|
|
55
|
+
/vibe - let Drexler choose his own adventure
|
|
56
|
+
/name [name] - view or assign Drexler's pet name
|
|
57
|
+
/profile - print Drexler's personnel file
|
|
36
58
|
/model - show or switch model (e.g. /model 26b)
|
|
37
59
|
/theme - show or switch theme (${THEME_NAMES.join(", ")})
|
|
38
60
|
/startup - persist startup mode (fast, no-intro, normal)
|
|
@@ -46,7 +68,9 @@ const HELP_TEXT = `New memo to staff! Drexler permit following directives:
|
|
|
46
68
|
/export <fmt> [path] - export as md, txt, json, or html
|
|
47
69
|
/save [path] - archive conversation to markdown file
|
|
48
70
|
/save-last [path] - save Drexler's last response
|
|
49
|
-
/copy-last - copy Drexler's last response to clipboard
|
|
71
|
+
/copy-last - copy Drexler's last response to clipboard
|
|
72
|
+
/setup - show config + API key source
|
|
73
|
+
/update - show upgrade instructions`;
|
|
50
74
|
|
|
51
75
|
const WHITESPACE_RE = /\s+/;
|
|
52
76
|
|
|
@@ -54,27 +78,38 @@ export interface SlashCommand {
|
|
|
54
78
|
readonly name: string;
|
|
55
79
|
readonly description: string;
|
|
56
80
|
readonly hint?: string;
|
|
81
|
+
readonly group?: CommandGroup;
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
export const COMMAND_PALETTE: ReadonlyArray<SlashCommand> = [
|
|
60
|
-
{ name: "/help", description: "Show directives" },
|
|
61
|
-
{ name: "/clear", description: "Reset conversation" },
|
|
62
|
-
{ name: "/exit", description: "Adjourn meeting" },
|
|
63
|
-
{ name: "/synergy", description: "SYNERGY!" },
|
|
64
|
-
{ name: "/
|
|
65
|
-
{ name: "/
|
|
66
|
-
{ name: "/
|
|
67
|
-
{ name: "/
|
|
68
|
-
{ name: "/
|
|
69
|
-
{ name: "/
|
|
70
|
-
{ name: "/
|
|
71
|
-
{ name: "/
|
|
72
|
-
{ name: "/
|
|
73
|
-
{ name: "/
|
|
74
|
-
{ name: "/
|
|
75
|
-
{ name: "/
|
|
76
|
-
{ name: "/
|
|
77
|
-
{ name: "/
|
|
85
|
+
{ name: "/help", description: "Show directives", group: "directives" },
|
|
86
|
+
{ name: "/clear", description: "Reset conversation", group: "directives" },
|
|
87
|
+
{ name: "/exit", description: "Adjourn meeting", group: "directives" },
|
|
88
|
+
{ name: "/synergy", description: "SYNERGY!", group: "directives" },
|
|
89
|
+
{ name: "/feed", description: "Feed Drexler a deal memo", group: "directives" },
|
|
90
|
+
{ name: "/play", description: "Play with Drexler", group: "directives" },
|
|
91
|
+
{ name: "/work", description: "Drexler grinds deals", group: "directives" },
|
|
92
|
+
{ name: "/praise", description: "Affirm Drexler", group: "directives" },
|
|
93
|
+
{ name: "/rest", description: "Drexler takes a strategic nap", group: "directives" },
|
|
94
|
+
{ name: "/vibe", description: "Drexler chooses his own adventure", group: "directives" },
|
|
95
|
+
{ name: "/name", description: "Issue or view Drexler's pet name", group: "directives" },
|
|
96
|
+
{ name: "/profile", description: "Print Drexler's personnel file", group: "directives" },
|
|
97
|
+
{ name: "/model", description: "Show or switch model", group: "models" },
|
|
98
|
+
{ name: "/theme", description: "Show or switch theme", group: "themes" },
|
|
99
|
+
{ name: "/startup", description: "Persist startup mode", group: "startup" },
|
|
100
|
+
{ name: "/history", description: "Message + token count", group: "directives" },
|
|
101
|
+
{ name: "/regenerate", description: "Re-roll last response", group: "directives" },
|
|
102
|
+
{ name: "/redo", description: "Alias for regenerate", group: "directives" },
|
|
103
|
+
{ name: "/retry", description: "Retry terse or brutal", group: "retry" },
|
|
104
|
+
{ name: "/expand", description: "Print last response", group: "directives" },
|
|
105
|
+
{ name: "/quote", description: "Quote last response", group: "directives" },
|
|
106
|
+
{ name: "/search", description: "Search transcript", group: "directives" },
|
|
107
|
+
{ name: "/export", description: "Export md, txt, json, or html", group: "export" },
|
|
108
|
+
{ name: "/save", description: "Archive conversation as markdown", group: "directives" },
|
|
109
|
+
{ name: "/save-last", description: "Save last Drexler response", group: "directives" },
|
|
110
|
+
{ name: "/copy-last", description: "Copy last response", group: "directives" },
|
|
111
|
+
{ name: "/setup", description: "Show config + key source", group: "directives" },
|
|
112
|
+
{ name: "/update", description: "Show upgrade instructions", group: "directives" },
|
|
78
113
|
];
|
|
79
114
|
|
|
80
115
|
const THEME_PALETTE_COPY: Record<
|
|
@@ -265,6 +300,21 @@ export function filterPaletteByPrefix(
|
|
|
265
300
|
);
|
|
266
301
|
}
|
|
267
302
|
|
|
303
|
+
const ARGUMENT_BASE_NAMES: ReadonlySet<string> = new Set(
|
|
304
|
+
ARGUMENT_PALETTE.map((g) => g.command),
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* True if `name` is a bare command (no space) that has child argument
|
|
309
|
+
* suggestions. Palette Enter on such a name should NOT execute — it should
|
|
310
|
+
* open the chooser.
|
|
311
|
+
*/
|
|
312
|
+
export function isArgumentParentCommand(name: string): boolean {
|
|
313
|
+
if (!name.startsWith("/")) return false;
|
|
314
|
+
if (name.includes(" ")) return false;
|
|
315
|
+
return ARGUMENT_BASE_NAMES.has(name.toLowerCase());
|
|
316
|
+
}
|
|
317
|
+
|
|
268
318
|
export function isSlash(input: string): boolean {
|
|
269
319
|
return input.startsWith("/");
|
|
270
320
|
}
|
|
@@ -319,6 +369,19 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
|
|
|
319
369
|
);
|
|
320
370
|
return { type: "continue" };
|
|
321
371
|
|
|
372
|
+
case "feed":
|
|
373
|
+
case "play":
|
|
374
|
+
case "work":
|
|
375
|
+
case "praise":
|
|
376
|
+
case "rest":
|
|
377
|
+
case "vibe":
|
|
378
|
+
case "name":
|
|
379
|
+
case "profile":
|
|
380
|
+
ctx.print(
|
|
381
|
+
"Drexler pet directives require the interactive deal desk. Launch Drexler in a TTY.",
|
|
382
|
+
);
|
|
383
|
+
return { type: "continue" };
|
|
384
|
+
|
|
322
385
|
case "model":
|
|
323
386
|
handleModel(args, ctx);
|
|
324
387
|
return { type: "continue" };
|
|
@@ -407,6 +470,14 @@ export function dispatch(input: string, ctx: CommandContext): CommandAction {
|
|
|
407
470
|
return { type: "continue" };
|
|
408
471
|
}
|
|
409
472
|
|
|
473
|
+
case "setup":
|
|
474
|
+
handleSetup(ctx);
|
|
475
|
+
return { type: "continue" };
|
|
476
|
+
|
|
477
|
+
case "update":
|
|
478
|
+
handleUpdate(ctx);
|
|
479
|
+
return { type: "continue" };
|
|
480
|
+
|
|
410
481
|
default:
|
|
411
482
|
ctx.print(
|
|
412
483
|
"Drexler not recognize that corporate directive. Try /help.",
|
|
@@ -869,3 +940,39 @@ function handleTheme(args: string[], ctx: CommandContext): CommandAction {
|
|
|
869
940
|
? { type: "continue", persistConfig: { theme: requested } }
|
|
870
941
|
: { type: "continue" };
|
|
871
942
|
}
|
|
943
|
+
|
|
944
|
+
function handleSetup(ctx: CommandContext): void {
|
|
945
|
+
const envValid = isValidApiKey(process.env.OPENROUTER_API_KEY);
|
|
946
|
+
const target = getResolvedConfigPath() ?? getConfigPath();
|
|
947
|
+
const keySourceLabel = envValid
|
|
948
|
+
? "(env: OPENROUTER_API_KEY)"
|
|
949
|
+
: isValidApiKey(ctx.config.apiKey)
|
|
950
|
+
? `(config file: ${target})`
|
|
951
|
+
: "(missing — first-run prompt will request one)";
|
|
952
|
+
|
|
953
|
+
const lines = [
|
|
954
|
+
"Drexler setup ledger:",
|
|
955
|
+
` version : ${getDrexlerVersion()}`,
|
|
956
|
+
` config file : ${target}`,
|
|
957
|
+
` API key : ${keySourceLabel}`,
|
|
958
|
+
` model : ${ctx.config.model}`,
|
|
959
|
+
` theme : ${currentThemeName(ctx.config)}`,
|
|
960
|
+
` startup mode : ${currentStartupMode(ctx.config)}`,
|
|
961
|
+
` persona file : ${ctx.config.personaPath}`,
|
|
962
|
+
];
|
|
963
|
+
ctx.print(lines.join("\n"));
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function handleUpdate(ctx: CommandContext): void {
|
|
967
|
+
const lines = [
|
|
968
|
+
`Drexler upgrade dossier (drexler v${getDrexlerVersion()}):`,
|
|
969
|
+
"",
|
|
970
|
+
" bun update: bun update -g drexler --latest",
|
|
971
|
+
" bun reinstall: bun install -g drexler@latest",
|
|
972
|
+
" npm: npm install -g drexler@latest",
|
|
973
|
+
" pnpm: pnpm add -g drexler@latest",
|
|
974
|
+
"",
|
|
975
|
+
"Drexler will not run installs. Type the command into your shell.",
|
|
976
|
+
];
|
|
977
|
+
ctx.print(lines.join("\n"));
|
|
978
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import {
|
|
3
3
|
chmod,
|
|
4
4
|
lstat,
|
|
@@ -16,6 +16,53 @@ import { MODEL_FALLBACK, MODEL_PRIMARY, THEME_NAMES } from "./types.ts";
|
|
|
16
16
|
|
|
17
17
|
const DEFAULT_MAX_HISTORY = 50;
|
|
18
18
|
|
|
19
|
+
export function getDrexlerVersion(): string {
|
|
20
|
+
try {
|
|
21
|
+
const pkgPath = join(import.meta.dir, "..", "package.json");
|
|
22
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
23
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
24
|
+
} catch {
|
|
25
|
+
return "0.0.0";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getConfigPath(): string {
|
|
30
|
+
return configPath();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getLegacyConfigPath(): string {
|
|
34
|
+
return legacyConfigPath();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getResolvedConfigPath(): string | null {
|
|
38
|
+
const cp = configPath();
|
|
39
|
+
const lp = legacyConfigPath();
|
|
40
|
+
if (existsSync(cp)) return cp;
|
|
41
|
+
if (existsSync(lp)) return lp;
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ApiKeySource = "env" | "config-file" | "missing";
|
|
46
|
+
|
|
47
|
+
export class LaunchConfigError extends Error {
|
|
48
|
+
readonly reason:
|
|
49
|
+
| "model-alias"
|
|
50
|
+
| "persona-path"
|
|
51
|
+
| "config-unreadable"
|
|
52
|
+
| "api-key-empty";
|
|
53
|
+
readonly detail: Record<string, unknown>;
|
|
54
|
+
constructor(
|
|
55
|
+
reason: LaunchConfigError["reason"],
|
|
56
|
+
message: string,
|
|
57
|
+
detail: Record<string, unknown> = {},
|
|
58
|
+
) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "LaunchConfigError";
|
|
61
|
+
this.reason = reason;
|
|
62
|
+
this.detail = detail;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
19
66
|
function getHome(): string {
|
|
20
67
|
return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
21
68
|
}
|
|
@@ -75,6 +122,35 @@ export function defaultPersonaPath(): string {
|
|
|
75
122
|
return resolve(import.meta.dir, "..", "prompts", "drexler.md");
|
|
76
123
|
}
|
|
77
124
|
|
|
125
|
+
async function validatePersonaFile(
|
|
126
|
+
inputPath: unknown,
|
|
127
|
+
label: string,
|
|
128
|
+
): Promise<string> {
|
|
129
|
+
if (typeof inputPath !== "string" || inputPath.trim().length === 0) {
|
|
130
|
+
throw new LaunchConfigError(
|
|
131
|
+
"persona-path",
|
|
132
|
+
`Invalid ${label}: expected a regular .md file path.`,
|
|
133
|
+
{ path: inputPath },
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const resolved = resolve(inputPath);
|
|
137
|
+
// lstat (not stat) so symlinks pointing to non-.md targets cannot bypass
|
|
138
|
+
// the extension check via `ln -s /etc/passwd evil.md`.
|
|
139
|
+
const st = await lstat(resolved).catch(() => null);
|
|
140
|
+
if (
|
|
141
|
+
!st?.isFile() ||
|
|
142
|
+
st.isSymbolicLink() ||
|
|
143
|
+
!resolved.toLowerCase().endsWith(".md")
|
|
144
|
+
) {
|
|
145
|
+
throw new LaunchConfigError(
|
|
146
|
+
"persona-path",
|
|
147
|
+
`Invalid ${label}: ${inputPath} (must be a regular .md file; symlinks rejected).`,
|
|
148
|
+
{ path: inputPath },
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
return resolved;
|
|
152
|
+
}
|
|
153
|
+
|
|
78
154
|
export async function loadConfigFile(): Promise<Partial<Config>> {
|
|
79
155
|
const cp = configPath();
|
|
80
156
|
const lp = legacyConfigPath();
|
|
@@ -192,43 +268,38 @@ export async function ensureApiKey(opts?: {
|
|
|
192
268
|
return apiKey;
|
|
193
269
|
}
|
|
194
270
|
|
|
195
|
-
export
|
|
271
|
+
export interface LaunchStructural {
|
|
272
|
+
model: string;
|
|
273
|
+
personaPath: string;
|
|
274
|
+
theme?: ThemeName;
|
|
275
|
+
noIntro?: boolean;
|
|
276
|
+
fast?: boolean;
|
|
277
|
+
maxHistory: number;
|
|
278
|
+
fileCfg: Partial<Config>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export async function validateLaunchConfig(
|
|
282
|
+
argv: string[],
|
|
283
|
+
): Promise<LaunchStructural> {
|
|
196
284
|
const flags = parseFlags(argv);
|
|
197
285
|
const fileCfg = await loadConfigFile();
|
|
198
|
-
const envKey = process.env.OPENROUTER_API_KEY;
|
|
199
286
|
const envModel = process.env.DREXLER_MODEL;
|
|
200
287
|
|
|
201
|
-
const apiKey = isValidApiKey(envKey)
|
|
202
|
-
? envKey.trim()
|
|
203
|
-
: isValidApiKey(fileCfg.apiKey)
|
|
204
|
-
? fileCfg.apiKey.trim()
|
|
205
|
-
: "";
|
|
206
|
-
|
|
207
|
-
if (!apiKey) {
|
|
208
|
-
throw new Error(
|
|
209
|
-
"API key missing. Run drexler interactively to set one, or export OPENROUTER_API_KEY.",
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
288
|
const modelInput = flags.model ?? envModel ?? fileCfg.model ?? "31b";
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
// the extension check via `ln -s /etc/passwd evil.md`.
|
|
221
|
-
const st = await lstat(resolved).catch(() => null);
|
|
222
|
-
if (!st?.isFile() || !resolved.toLowerCase().endsWith(".md")) {
|
|
223
|
-
throw new Error(
|
|
224
|
-
`Invalid --persona: ${flags.persona} (must be a regular .md file; symlinks rejected).`,
|
|
225
|
-
);
|
|
226
|
-
}
|
|
227
|
-
personaPath = resolved;
|
|
228
|
-
} else {
|
|
229
|
-
personaPath = fileCfg.personaPath ?? defaultPersonaPath();
|
|
289
|
+
let model: string;
|
|
290
|
+
try {
|
|
291
|
+
model = resolveModel(modelInput);
|
|
292
|
+
} catch (e) {
|
|
293
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
294
|
+
throw new LaunchConfigError("model-alias", msg, { input: modelInput });
|
|
230
295
|
}
|
|
231
296
|
|
|
297
|
+
const personaPath = flags.persona
|
|
298
|
+
? await validatePersonaFile(flags.persona, "--persona")
|
|
299
|
+
: fileCfg.personaPath !== undefined
|
|
300
|
+
? await validatePersonaFile(fileCfg.personaPath, "config personaPath")
|
|
301
|
+
: await validatePersonaFile(defaultPersonaPath(), "default persona");
|
|
302
|
+
|
|
232
303
|
const maxHistory =
|
|
233
304
|
typeof fileCfg.maxHistory === "number" &&
|
|
234
305
|
Number.isInteger(fileCfg.maxHistory) &&
|
|
@@ -253,5 +324,43 @@ export async function resolveConfig(argv: string[]): Promise<Config> {
|
|
|
253
324
|
parseOptionalBoolean(process.env.DREXLER_FAST) ??
|
|
254
325
|
parseOptionalBoolean(fileCfg.fast);
|
|
255
326
|
|
|
256
|
-
return {
|
|
327
|
+
return { model, personaPath, theme, noIntro, fast, maxHistory, fileCfg };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export async function describeApiKeySource(): Promise<{
|
|
331
|
+
source: ApiKeySource;
|
|
332
|
+
configPath: string | null;
|
|
333
|
+
}> {
|
|
334
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
335
|
+
if (isValidApiKey(envKey)) return { source: "env", configPath: null };
|
|
336
|
+
const fileCfg = await loadConfigFile();
|
|
337
|
+
if (isValidApiKey(fileCfg.apiKey)) {
|
|
338
|
+
return { source: "config-file", configPath: getResolvedConfigPath() };
|
|
339
|
+
}
|
|
340
|
+
return { source: "missing", configPath: null };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export async function resolveConfig(argv: string[]): Promise<Config> {
|
|
344
|
+
const structural = await validateLaunchConfig(argv);
|
|
345
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
346
|
+
const apiKey = isValidApiKey(envKey)
|
|
347
|
+
? envKey.trim()
|
|
348
|
+
: isValidApiKey(structural.fileCfg.apiKey)
|
|
349
|
+
? structural.fileCfg.apiKey.trim()
|
|
350
|
+
: "";
|
|
351
|
+
if (!apiKey) {
|
|
352
|
+
throw new LaunchConfigError(
|
|
353
|
+
"api-key-empty",
|
|
354
|
+
"API key missing. Run drexler interactively to set one, or export OPENROUTER_API_KEY.",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
apiKey,
|
|
359
|
+
model: structural.model,
|
|
360
|
+
maxHistory: structural.maxHistory,
|
|
361
|
+
personaPath: structural.personaPath,
|
|
362
|
+
theme: structural.theme,
|
|
363
|
+
noIntro: structural.noIntro,
|
|
364
|
+
fast: structural.fast,
|
|
365
|
+
};
|
|
257
366
|
}
|
package/src/conversation.ts
CHANGED
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 {
|
|
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,14 @@ 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
|
|
66
|
+
/name [name] view or assign Drexler's pet name
|
|
67
|
+
/profile print Drexler's personnel file
|
|
55
68
|
/model [id] show or switch model
|
|
56
69
|
/theme [name] show or switch theme; append save to persist
|
|
57
70
|
/startup [mode] persist startup mode: fast, no-intro, normal
|
|
@@ -66,6 +79,8 @@ Slash commands inside REPL:
|
|
|
66
79
|
/save [path] archive conversation as markdown
|
|
67
80
|
/save-last [path] save latest response
|
|
68
81
|
/copy-last copy latest response to clipboard
|
|
82
|
+
/setup show config + API key source
|
|
83
|
+
/update show upgrade instructions
|
|
69
84
|
|
|
70
85
|
Ctrl+C exits gracefully.`;
|
|
71
86
|
|
|
@@ -85,18 +100,39 @@ async function main(): Promise<void> {
|
|
|
85
100
|
const isInteractive =
|
|
86
101
|
process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
87
102
|
|
|
88
|
-
//
|
|
103
|
+
// 1. Validate non-secret config FIRST so a bogus --model or --persona
|
|
104
|
+
// fails fast before we ask the user for an API key.
|
|
105
|
+
try {
|
|
106
|
+
await validateLaunchConfig(argv);
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e instanceof LaunchConfigError) {
|
|
109
|
+
console.error(error(formatLaunchError(e)));
|
|
110
|
+
} else {
|
|
111
|
+
console.error(
|
|
112
|
+
error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 2. Acquire API key (may prompt). Runs after validation so bad CLI args
|
|
119
|
+
// no longer trigger the first-run setup flow.
|
|
89
120
|
await ensureApiKey({
|
|
90
121
|
prompt: isInteractive ? promptForApiKeyWithInk : undefined,
|
|
91
122
|
});
|
|
92
123
|
|
|
124
|
+
// 3. Resolve full Config (API key now present).
|
|
93
125
|
let config;
|
|
94
126
|
try {
|
|
95
127
|
config = await resolveConfig(argv);
|
|
96
128
|
} catch (e) {
|
|
97
|
-
|
|
98
|
-
error(
|
|
99
|
-
|
|
129
|
+
if (e instanceof LaunchConfigError) {
|
|
130
|
+
console.error(error(formatLaunchError(e)));
|
|
131
|
+
} else {
|
|
132
|
+
console.error(
|
|
133
|
+
error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
100
136
|
process.exit(1);
|
|
101
137
|
}
|
|
102
138
|
|
|
@@ -154,6 +190,7 @@ async function main(): Promise<void> {
|
|
|
154
190
|
}
|
|
155
191
|
|
|
156
192
|
// Non-TTY fallback: linear output, readline-based REPL.
|
|
193
|
+
installFatalHandlers();
|
|
157
194
|
console.log("");
|
|
158
195
|
if (!skipIntro) {
|
|
159
196
|
console.log(banner());
|
|
@@ -172,6 +209,34 @@ async function main(): Promise<void> {
|
|
|
172
209
|
});
|
|
173
210
|
}
|
|
174
211
|
|
|
212
|
+
function formatLaunchError(e: LaunchConfigError): string {
|
|
213
|
+
switch (e.reason) {
|
|
214
|
+
case "model-alias":
|
|
215
|
+
return `Bad model alias: ${e.message}`;
|
|
216
|
+
case "persona-path":
|
|
217
|
+
return `Bad persona file: ${e.message}`;
|
|
218
|
+
case "config-unreadable":
|
|
219
|
+
return `Config file unreadable: ${e.message}`;
|
|
220
|
+
case "api-key-empty":
|
|
221
|
+
return `API key required: ${e.message}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fatal handlers are installed only in the non-TTY path; the interactive
|
|
226
|
+
// path lets Ink's signal-exit hooks run cleanup so the alt-screen restores.
|
|
227
|
+
function installFatalHandlers(): void {
|
|
228
|
+
process.on("unhandledRejection", (reason) => {
|
|
229
|
+
const msg =
|
|
230
|
+
reason instanceof Error ? (reason.stack ?? reason.message) : String(reason);
|
|
231
|
+
console.error(error("Unhandled rejection:"), msg);
|
|
232
|
+
process.exitCode = 1;
|
|
233
|
+
});
|
|
234
|
+
process.on("uncaughtException", (err) => {
|
|
235
|
+
console.error(error("Uncaught exception:"), err.stack ?? err.message);
|
|
236
|
+
process.exitCode = 1;
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
175
240
|
main().catch((e) => {
|
|
176
241
|
console.error(error("Fatal:"), e);
|
|
177
242
|
process.exit(1);
|