drexler 0.1.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/LICENSE +21 -0
- package/README.md +84 -0
- package/package.json +61 -0
- package/prompts/drexler.md +238 -0
- package/src/commands.ts +207 -0
- package/src/config.ts +222 -0
- package/src/conversation.ts +79 -0
- package/src/index.ts +165 -0
- package/src/llm.ts +223 -0
- package/src/mood.ts +19 -0
- package/src/persona.ts +43 -0
- package/src/renderer.ts +412 -0
- package/src/repl.ts +225 -0
- package/src/sayings.ts +96 -0
- package/src/startupTips.ts +6 -0
- package/src/types.ts +44 -0
- package/src/ui/App.tsx +481 -0
- package/src/ui/CommandPalette.tsx +36 -0
- package/src/ui/InputBox.tsx +39 -0
- package/src/ui/MascotFrame.tsx +70 -0
- package/src/ui/MascotIntro.tsx +338 -0
- package/src/ui/Message.tsx +82 -0
- package/src/ui/Spinner.tsx +39 -0
- package/src/ui/StatusBar.tsx +61 -0
- package/src/ui/ThemeContext.tsx +10 -0
- package/src/ui/themes.ts +88 -0
package/src/config.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import {
|
|
3
|
+
chmod,
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
readFile,
|
|
7
|
+
rename,
|
|
8
|
+
unlink,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { join, resolve } from "node:path";
|
|
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";
|
|
16
|
+
|
|
17
|
+
const DEFAULT_MAX_HISTORY = 50;
|
|
18
|
+
|
|
19
|
+
function getHome(): string {
|
|
20
|
+
return process.env.HOME ?? process.env.USERPROFILE ?? homedir();
|
|
21
|
+
}
|
|
22
|
+
function configDir(): string {
|
|
23
|
+
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
|
24
|
+
if (xdg && xdg.length > 0) return join(xdg, "drexler");
|
|
25
|
+
return join(getHome(), ".config", "drexler");
|
|
26
|
+
}
|
|
27
|
+
function configPath(): string {
|
|
28
|
+
return join(configDir(), "config.json");
|
|
29
|
+
}
|
|
30
|
+
function legacyConfigPath(): string {
|
|
31
|
+
return join(getHome(), ".drexlerrc");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const MODEL_ID_PATTERN = /^[a-z0-9._-]+\/[a-z0-9._-]+(?::[a-z0-9]+)?$/i;
|
|
35
|
+
|
|
36
|
+
export function parseFlags(argv: string[]): CliFlags {
|
|
37
|
+
const flags: CliFlags = {};
|
|
38
|
+
const valueAfter = (i: number): string | undefined => {
|
|
39
|
+
const value = argv[i + 1];
|
|
40
|
+
return value !== undefined && !value.startsWith("--") ? value : undefined;
|
|
41
|
+
};
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i];
|
|
44
|
+
if (a === "--model" && valueAfter(i) !== undefined) {
|
|
45
|
+
flags.model = argv[++i];
|
|
46
|
+
} else if (a === "--persona" && valueAfter(i) !== undefined) {
|
|
47
|
+
flags.persona = argv[++i];
|
|
48
|
+
} else if (a === "--theme" && valueAfter(i) !== undefined) {
|
|
49
|
+
flags.theme = argv[++i];
|
|
50
|
+
} else if (a !== undefined && a.startsWith("--model=")) {
|
|
51
|
+
flags.model = a.slice("--model=".length);
|
|
52
|
+
} else if (a !== undefined && a.startsWith("--persona=")) {
|
|
53
|
+
flags.persona = a.slice("--persona=".length);
|
|
54
|
+
} else if (a !== undefined && a.startsWith("--theme=")) {
|
|
55
|
+
flags.theme = a.slice("--theme=".length);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return flags;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function resolveModel(input: string): string {
|
|
62
|
+
if (input === "31b") return MODEL_PRIMARY;
|
|
63
|
+
if (input === "26b") return MODEL_FALLBACK;
|
|
64
|
+
if (MODEL_ID_PATTERN.test(input)) return input;
|
|
65
|
+
throw new Error(
|
|
66
|
+
`Unknown model: "${input}". Use 31b, 26b, or full id like google/gemma-4-31b-it.`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function defaultPersonaPath(): string {
|
|
71
|
+
return resolve(import.meta.dir, "..", "prompts", "drexler.md");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function loadConfigFile(): Promise<Partial<Config>> {
|
|
75
|
+
const cp = configPath();
|
|
76
|
+
const lp = legacyConfigPath();
|
|
77
|
+
const path = existsSync(cp) ? cp : existsSync(lp) ? lp : null;
|
|
78
|
+
if (!path) return {};
|
|
79
|
+
try {
|
|
80
|
+
const raw = await readFile(path, "utf-8");
|
|
81
|
+
const parsed: unknown = JSON.parse(raw);
|
|
82
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
83
|
+
console.warn(
|
|
84
|
+
`Drexler config at ${path} is not a JSON object; ignoring (defaults applied).`,
|
|
85
|
+
);
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
return parsed as Partial<Config>;
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
91
|
+
console.warn(
|
|
92
|
+
`Drexler config at ${path} could not be read (${msg}); ignoring (defaults applied).`,
|
|
93
|
+
);
|
|
94
|
+
return {};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function saveConfig(partial: Partial<Config>): Promise<void> {
|
|
99
|
+
// Known limitation: concurrent drexler instances racing on saveConfig can
|
|
100
|
+
// lose one side's merge (read-modify-write TOCTOU). Acceptable for
|
|
101
|
+
// single-user CLI; revisit with proper-lockfile if write frequency grows.
|
|
102
|
+
const existing = await loadConfigFile();
|
|
103
|
+
const merged = { ...existing, ...partial };
|
|
104
|
+
const dir = configDir();
|
|
105
|
+
const target = configPath();
|
|
106
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
107
|
+
// Atomic write: temp file + rename, mode 0600 (config holds API key).
|
|
108
|
+
const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
|
|
109
|
+
try {
|
|
110
|
+
await writeFile(tmp, JSON.stringify(merged, null, 2), {
|
|
111
|
+
encoding: "utf-8",
|
|
112
|
+
mode: 0o600,
|
|
113
|
+
});
|
|
114
|
+
await rename(tmp, target);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
await unlink(tmp).catch(() => {});
|
|
117
|
+
throw err;
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
await chmod(target, 0o600);
|
|
121
|
+
} catch {}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const PLACEHOLDER_RE = /your-key-here|sk-or-v1-\.\.\.|^(stub|test|todo)$/i;
|
|
125
|
+
const MIN_KEY_LEN = 20;
|
|
126
|
+
|
|
127
|
+
export function isValidApiKey(k: string | undefined | null): k is string {
|
|
128
|
+
if (typeof k !== "string") return false;
|
|
129
|
+
const t = k.trim();
|
|
130
|
+
if (t.length < MIN_KEY_LEN) return false;
|
|
131
|
+
if (PLACEHOLDER_RE.test(t)) return false;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readApiKeyFromStdin(): Promise<string | null> {
|
|
136
|
+
const rl = readline.createInterface({
|
|
137
|
+
input: process.stdin,
|
|
138
|
+
output: process.stdout,
|
|
139
|
+
});
|
|
140
|
+
try {
|
|
141
|
+
const ans = await rl.question("Enter OpenRouter API key: ");
|
|
142
|
+
const trimmed = ans.trim();
|
|
143
|
+
return trimmed.length > 0 ? trimmed : null;
|
|
144
|
+
} finally {
|
|
145
|
+
rl.close();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function ensureApiKey(): Promise<string> {
|
|
150
|
+
const fileCfg = await loadConfigFile();
|
|
151
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
152
|
+
if (isValidApiKey(envKey)) return envKey.trim();
|
|
153
|
+
if (isValidApiKey(fileCfg.apiKey)) return fileCfg.apiKey.trim();
|
|
154
|
+
|
|
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");
|
|
157
|
+
|
|
158
|
+
const entered = await readApiKeyFromStdin();
|
|
159
|
+
if (!isValidApiKey(entered)) {
|
|
160
|
+
console.error("No valid API key provided. Drexler refuse to work pro bono.");
|
|
161
|
+
process.exit(1);
|
|
162
|
+
}
|
|
163
|
+
const apiKey = entered.trim();
|
|
164
|
+
await saveConfig({ apiKey });
|
|
165
|
+
return apiKey;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function resolveConfig(argv: string[]): Promise<Config> {
|
|
169
|
+
const flags = parseFlags(argv);
|
|
170
|
+
const fileCfg = await loadConfigFile();
|
|
171
|
+
const envKey = process.env.OPENROUTER_API_KEY;
|
|
172
|
+
const envModel = process.env.DREXLER_MODEL;
|
|
173
|
+
|
|
174
|
+
const apiKey = isValidApiKey(envKey)
|
|
175
|
+
? envKey.trim()
|
|
176
|
+
: isValidApiKey(fileCfg.apiKey)
|
|
177
|
+
? fileCfg.apiKey.trim()
|
|
178
|
+
: "";
|
|
179
|
+
|
|
180
|
+
if (!apiKey) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"API key missing. Run drexler interactively to set one, or export OPENROUTER_API_KEY.",
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const modelInput = flags.model ?? envModel ?? fileCfg.model ?? "31b";
|
|
187
|
+
const model = resolveModel(modelInput);
|
|
188
|
+
|
|
189
|
+
let personaPath: string;
|
|
190
|
+
if (flags.persona) {
|
|
191
|
+
const resolved = resolve(flags.persona);
|
|
192
|
+
// lstat (not stat) so symlinks pointing to non-.md targets cannot bypass
|
|
193
|
+
// the extension check via `ln -s /etc/passwd evil.md`.
|
|
194
|
+
const st = await lstat(resolved).catch(() => null);
|
|
195
|
+
if (!st?.isFile() || !resolved.toLowerCase().endsWith(".md")) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`Invalid --persona: ${flags.persona} (must be a regular .md file; symlinks rejected).`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
personaPath = resolved;
|
|
201
|
+
} else {
|
|
202
|
+
personaPath = fileCfg.personaPath ?? defaultPersonaPath();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const maxHistory =
|
|
206
|
+
typeof fileCfg.maxHistory === "number" &&
|
|
207
|
+
Number.isInteger(fileCfg.maxHistory) &&
|
|
208
|
+
fileCfg.maxHistory >= 3
|
|
209
|
+
? fileCfg.maxHistory
|
|
210
|
+
: DEFAULT_MAX_HISTORY;
|
|
211
|
+
|
|
212
|
+
const themeCandidate =
|
|
213
|
+
flags.theme ?? process.env.DREXLER_THEME ?? fileCfg.theme;
|
|
214
|
+
const theme =
|
|
215
|
+
themeCandidate === "apollo" ||
|
|
216
|
+
themeCandidate === "amber" ||
|
|
217
|
+
themeCandidate === "mono"
|
|
218
|
+
? themeCandidate
|
|
219
|
+
: undefined;
|
|
220
|
+
|
|
221
|
+
return { apiKey, model, maxHistory, personaPath, theme };
|
|
222
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Message, Role } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export class Conversation {
|
|
4
|
+
private messages: Message[];
|
|
5
|
+
private readonly system: Message;
|
|
6
|
+
private userTurnCount = 0;
|
|
7
|
+
|
|
8
|
+
constructor(
|
|
9
|
+
systemPrompt: string,
|
|
10
|
+
public readonly maxHistory: number,
|
|
11
|
+
) {
|
|
12
|
+
if (maxHistory < 3) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
"maxHistory must be >= 3 (system + user + assistant turn).",
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
this.system = { role: "system", content: systemPrompt };
|
|
18
|
+
this.messages = [this.system];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
push(role: Exclude<Role, "system">, content: string): void {
|
|
22
|
+
this.messages.push({ role, content });
|
|
23
|
+
if (role === "user") this.userTurnCount++;
|
|
24
|
+
this.trim();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private trim(): void {
|
|
28
|
+
while (this.messages.length > this.maxHistory) {
|
|
29
|
+
this.messages.splice(1, 1);
|
|
30
|
+
if (this.messages[1]?.role === "assistant") {
|
|
31
|
+
this.messages.splice(1, 1);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
clear(): void {
|
|
37
|
+
this.messages = [this.system];
|
|
38
|
+
this.userTurnCount = 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
popLastAssistant(): boolean {
|
|
42
|
+
const last = this.messages[this.messages.length - 1];
|
|
43
|
+
if (last && last.role === "assistant") {
|
|
44
|
+
this.messages.pop();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lastUserMessage(): string | null {
|
|
51
|
+
for (let i = this.messages.length - 1; i >= 0; i--) {
|
|
52
|
+
const m = this.messages[i];
|
|
53
|
+
if (m && m.role === "user") return m.content;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
snapshot(): Message[] {
|
|
59
|
+
return this.messages.slice();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get length(): number {
|
|
63
|
+
return this.messages.length - 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get totalLength(): number {
|
|
67
|
+
return this.messages.length;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get userTurns(): number {
|
|
71
|
+
return this.userTurnCount;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
approximateTokens(): number {
|
|
75
|
+
let chars = 0;
|
|
76
|
+
for (const m of this.messages) chars += m.content.length;
|
|
77
|
+
return Math.ceil(chars / 4);
|
|
78
|
+
}
|
|
79
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { render } from "ink";
|
|
6
|
+
import { ensureApiKey, resolveConfig } from "./config.ts";
|
|
7
|
+
import { Conversation } from "./conversation.ts";
|
|
8
|
+
import { moodLine, pickMood } from "./mood.ts";
|
|
9
|
+
import { loadPersona, pickGreeting } from "./persona.ts";
|
|
10
|
+
import {
|
|
11
|
+
banner,
|
|
12
|
+
error,
|
|
13
|
+
infoLine,
|
|
14
|
+
resetMarkedTheme,
|
|
15
|
+
tagline,
|
|
16
|
+
termCols,
|
|
17
|
+
typewriterBanner,
|
|
18
|
+
welcomeBox,
|
|
19
|
+
} from "./renderer.ts";
|
|
20
|
+
import { startRepl } from "./repl.ts";
|
|
21
|
+
import { App } from "./ui/App.tsx";
|
|
22
|
+
import { MascotIntro } from "./ui/MascotIntro.tsx";
|
|
23
|
+
import { ThemeProvider } from "./ui/ThemeContext.tsx";
|
|
24
|
+
import { getActiveTheme, selectTheme, setActiveTheme } from "./ui/themes.ts";
|
|
25
|
+
|
|
26
|
+
function getVersion(): string {
|
|
27
|
+
try {
|
|
28
|
+
const pkg = JSON.parse(
|
|
29
|
+
readFileSync(join(import.meta.dir, "..", "package.json"), "utf-8"),
|
|
30
|
+
);
|
|
31
|
+
return typeof pkg.version === "string" ? pkg.version : "0.0.0";
|
|
32
|
+
} catch {
|
|
33
|
+
return "0.0.0";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const USAGE = `drexler — CLI chat with corporate-exec persona
|
|
38
|
+
|
|
39
|
+
Usage: drexler [options]
|
|
40
|
+
|
|
41
|
+
Options:
|
|
42
|
+
--model <31b|26b|id> model alias or full OpenRouter id
|
|
43
|
+
--persona <path> custom persona markdown
|
|
44
|
+
--theme <apollo|amber|mono> color theme (default apollo)
|
|
45
|
+
--version, -v print version
|
|
46
|
+
--help, -h this help
|
|
47
|
+
|
|
48
|
+
Slash commands inside REPL:
|
|
49
|
+
/help show directives
|
|
50
|
+
/clear reset conversation
|
|
51
|
+
/exit exit
|
|
52
|
+
/synergy SYNERGY!
|
|
53
|
+
/model [id] show or switch model
|
|
54
|
+
/history message + token count
|
|
55
|
+
/regenerate re-roll last response
|
|
56
|
+
/save [path] archive conversation as markdown
|
|
57
|
+
|
|
58
|
+
Ctrl+C exits gracefully.`;
|
|
59
|
+
|
|
60
|
+
async function main(): Promise<void> {
|
|
61
|
+
const argv = process.argv.slice(2);
|
|
62
|
+
|
|
63
|
+
if (argv.includes("--version") || argv.includes("-v")) {
|
|
64
|
+
console.log(getVersion());
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
69
|
+
console.log(USAGE);
|
|
70
|
+
process.exit(0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Acquire API key. Prompts interactively if missing — runs BEFORE banner.
|
|
74
|
+
await ensureApiKey();
|
|
75
|
+
|
|
76
|
+
let config;
|
|
77
|
+
try {
|
|
78
|
+
config = await resolveConfig(argv);
|
|
79
|
+
} catch (e) {
|
|
80
|
+
console.error(
|
|
81
|
+
error(`Drexler config tantrum: ${e instanceof Error ? e.message : e}`),
|
|
82
|
+
);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// resolveConfig already merged flag > env > file into config.theme.
|
|
87
|
+
// selectTheme just applies NO_COLOR override + default fallback.
|
|
88
|
+
const themeName = selectTheme({ flag: config.theme });
|
|
89
|
+
setActiveTheme(themeName);
|
|
90
|
+
resetMarkedTheme(); // ensure markdown picks up the freshly chosen theme
|
|
91
|
+
|
|
92
|
+
let persona;
|
|
93
|
+
try {
|
|
94
|
+
persona = await loadPersona(config.personaPath);
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error(error(e instanceof Error ? e.message : String(e)));
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const mood = pickMood();
|
|
101
|
+
const systemPromptWithMood = persona.systemPrompt + moodLine(mood);
|
|
102
|
+
const greeting = pickGreeting(persona.greetings);
|
|
103
|
+
|
|
104
|
+
const conversation = new Conversation(
|
|
105
|
+
systemPromptWithMood,
|
|
106
|
+
config.maxHistory,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const isInteractive =
|
|
110
|
+
process.stdout.isTTY === true && process.stdin.isTTY === true;
|
|
111
|
+
|
|
112
|
+
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();
|
|
129
|
+
|
|
130
|
+
console.log("");
|
|
131
|
+
console.log(" " + infoLine() + " · mood: " + mood);
|
|
132
|
+
console.log("");
|
|
133
|
+
|
|
134
|
+
const { waitUntilExit } = render(
|
|
135
|
+
React.createElement(ThemeProvider, {
|
|
136
|
+
value: getActiveTheme(),
|
|
137
|
+
children: React.createElement(App, { conversation, config }),
|
|
138
|
+
}),
|
|
139
|
+
{ exitOnCtrlC: false },
|
|
140
|
+
);
|
|
141
|
+
await waitUntilExit();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Non-TTY fallback: linear output, readline-based REPL.
|
|
146
|
+
console.log("");
|
|
147
|
+
console.log(banner());
|
|
148
|
+
console.log(tagline());
|
|
149
|
+
console.log("");
|
|
150
|
+
console.log(welcomeBox(greeting, termCols()));
|
|
151
|
+
console.log("");
|
|
152
|
+
console.log(" " + infoLine() + " · mood: " + mood);
|
|
153
|
+
console.log("");
|
|
154
|
+
|
|
155
|
+
await startRepl({
|
|
156
|
+
conversation,
|
|
157
|
+
config,
|
|
158
|
+
print: (s) => console.log(s),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
main().catch((e) => {
|
|
163
|
+
console.error(error("Fatal:"), e);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
});
|
package/src/llm.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Message, OpenRouterRequestBody, StreamChunk } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
const OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions";
|
|
4
|
+
|
|
5
|
+
const MAX_TOKENS = 350;
|
|
6
|
+
const TEMPERATURE = 0.95;
|
|
7
|
+
const STOP_SEQUENCES = [
|
|
8
|
+
"Meeting adjourned.",
|
|
9
|
+
"Severance package incoming.",
|
|
10
|
+
"Not culture-fit.",
|
|
11
|
+
];
|
|
12
|
+
const RETRY_DELAY_MS = 250;
|
|
13
|
+
|
|
14
|
+
export type FetchFn = (
|
|
15
|
+
url: string | URL | Request,
|
|
16
|
+
init?: RequestInit,
|
|
17
|
+
) => Promise<Response>;
|
|
18
|
+
|
|
19
|
+
export interface StreamOptions {
|
|
20
|
+
apiKey: string;
|
|
21
|
+
model: string;
|
|
22
|
+
fallbackModel?: string;
|
|
23
|
+
messages: Message[];
|
|
24
|
+
onToken: (token: string) => void;
|
|
25
|
+
signal?: AbortSignal;
|
|
26
|
+
fetchFn?: FetchFn;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface StreamResult {
|
|
30
|
+
ok: boolean;
|
|
31
|
+
content: string;
|
|
32
|
+
modelUsed: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
fellBack?: boolean;
|
|
35
|
+
interrupted?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function streamChat(opts: StreamOptions): Promise<StreamResult> {
|
|
39
|
+
const fetchFn = opts.fetchFn ?? fetch;
|
|
40
|
+
const first = await attempt(opts.model, opts, fetchFn);
|
|
41
|
+
if (
|
|
42
|
+
first.status === "rate_limit" &&
|
|
43
|
+
opts.fallbackModel &&
|
|
44
|
+
opts.fallbackModel !== opts.model
|
|
45
|
+
) {
|
|
46
|
+
const second = await attempt(opts.fallbackModel, opts, fetchFn);
|
|
47
|
+
return toResult(second, opts.fallbackModel, true);
|
|
48
|
+
}
|
|
49
|
+
return toResult(first, opts.model, false);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type AttemptStatus = "ok" | "rate_limit" | "http_error" | "stream_error";
|
|
53
|
+
|
|
54
|
+
interface AttemptOutcome {
|
|
55
|
+
status: AttemptStatus;
|
|
56
|
+
content: string;
|
|
57
|
+
error?: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function attempt(
|
|
61
|
+
model: string,
|
|
62
|
+
opts: StreamOptions,
|
|
63
|
+
fetchFn: FetchFn,
|
|
64
|
+
isRetry: boolean = false,
|
|
65
|
+
): Promise<AttemptOutcome> {
|
|
66
|
+
const body: OpenRouterRequestBody = {
|
|
67
|
+
model,
|
|
68
|
+
messages: opts.messages,
|
|
69
|
+
stream: true,
|
|
70
|
+
max_tokens: MAX_TOKENS,
|
|
71
|
+
temperature: TEMPERATURE,
|
|
72
|
+
stop: STOP_SEQUENCES,
|
|
73
|
+
};
|
|
74
|
+
let res: Response;
|
|
75
|
+
try {
|
|
76
|
+
res = await fetchFn(OPENROUTER_URL, {
|
|
77
|
+
method: "POST",
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${opts.apiKey}`,
|
|
80
|
+
"Content-Type": "application/json",
|
|
81
|
+
"HTTP-Referer": "https://github.com/showOS/Drexler",
|
|
82
|
+
"X-Title": "Drexler CLI",
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal: opts.signal,
|
|
86
|
+
});
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
status: "http_error",
|
|
90
|
+
content: "",
|
|
91
|
+
error: err instanceof Error ? err.message : String(err),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (res.status === 429) {
|
|
96
|
+
return { status: "rate_limit", content: "", error: "429 rate limited" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (res.status === 401 || res.status === 403) {
|
|
100
|
+
let detail = "";
|
|
101
|
+
try {
|
|
102
|
+
detail = await res.text();
|
|
103
|
+
} catch {}
|
|
104
|
+
return {
|
|
105
|
+
status: "http_error",
|
|
106
|
+
content: "",
|
|
107
|
+
error: `HTTP ${res.status}: API key rejected by OpenRouter. Update via .env (OPENROUTER_API_KEY=...) or run "rm ~/.config/drexler/config.json" to re-prompt. ${detail.slice(0, 120)}`,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (res.status >= 500 && res.status <= 599 && !isRetry) {
|
|
112
|
+
try {
|
|
113
|
+
await res.text();
|
|
114
|
+
} catch {}
|
|
115
|
+
await new Promise((r) => setTimeout(r, RETRY_DELAY_MS));
|
|
116
|
+
return attempt(model, opts, fetchFn, true);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!res.ok) {
|
|
120
|
+
let detail = "";
|
|
121
|
+
try {
|
|
122
|
+
detail = await res.text();
|
|
123
|
+
} catch {}
|
|
124
|
+
return {
|
|
125
|
+
status: "http_error",
|
|
126
|
+
content: "",
|
|
127
|
+
error: `HTTP ${res.status}: ${detail.slice(0, 200)}`,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!res.body) {
|
|
132
|
+
return { status: "stream_error", content: "", error: "No response body" };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const parsed = await parseSSEStream(res.body, opts.onToken);
|
|
136
|
+
if (!parsed.complete) {
|
|
137
|
+
return {
|
|
138
|
+
status: "stream_error",
|
|
139
|
+
content: parsed.content,
|
|
140
|
+
error: "Stream interrupted",
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { status: "ok", content: parsed.content };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function toResult(
|
|
147
|
+
outcome: AttemptOutcome,
|
|
148
|
+
modelUsed: string,
|
|
149
|
+
fellBack: boolean,
|
|
150
|
+
): StreamResult {
|
|
151
|
+
return {
|
|
152
|
+
ok: outcome.status === "ok",
|
|
153
|
+
content: outcome.content,
|
|
154
|
+
modelUsed,
|
|
155
|
+
error: outcome.error,
|
|
156
|
+
fellBack,
|
|
157
|
+
interrupted:
|
|
158
|
+
outcome.status === "stream_error" && outcome.content.length > 0,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface SSEParseResult {
|
|
163
|
+
content: string;
|
|
164
|
+
complete: boolean;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function parseSSEStream(
|
|
168
|
+
body: ReadableStream<Uint8Array>,
|
|
169
|
+
onToken: (token: string) => void,
|
|
170
|
+
): Promise<SSEParseResult> {
|
|
171
|
+
const reader = body.getReader();
|
|
172
|
+
const decoder = new TextDecoder();
|
|
173
|
+
let buf = "";
|
|
174
|
+
let acc = "";
|
|
175
|
+
let doneSeen = false;
|
|
176
|
+
const processLine = (rawLine: string): void => {
|
|
177
|
+
const line = rawLine.replace(/\r$/, "").trim();
|
|
178
|
+
if (line === "" || line.startsWith(":")) return;
|
|
179
|
+
if (!line.startsWith("data:")) return;
|
|
180
|
+
const data = line.slice(5).trim();
|
|
181
|
+
if (data.toUpperCase() === "[DONE]") {
|
|
182
|
+
doneSeen = true;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const chunk = JSON.parse(data) as StreamChunk;
|
|
187
|
+
const tok = chunk.choices?.[0]?.delta?.content;
|
|
188
|
+
if (typeof tok === "string" && tok.length > 0) {
|
|
189
|
+
acc += tok;
|
|
190
|
+
onToken(tok);
|
|
191
|
+
}
|
|
192
|
+
} catch {
|
|
193
|
+
// tolerate malformed chunk
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
while (!doneSeen) {
|
|
199
|
+
const { value, done } = await reader.read();
|
|
200
|
+
if (done) {
|
|
201
|
+
// Flush any incomplete UTF-8 sequence at end of stream.
|
|
202
|
+
buf += decoder.decode();
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
if (value) buf += decoder.decode(value, { stream: true });
|
|
206
|
+
let nl: number;
|
|
207
|
+
while ((nl = buf.indexOf("\n")) !== -1) {
|
|
208
|
+
const rawLine = buf.slice(0, nl);
|
|
209
|
+
buf = buf.slice(nl + 1);
|
|
210
|
+
processLine(rawLine);
|
|
211
|
+
if (doneSeen) return { content: acc, complete: true };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (buf.length > 0) processLine(buf);
|
|
215
|
+
return { content: acc, complete: doneSeen };
|
|
216
|
+
} catch {
|
|
217
|
+
return { content: acc, complete: false };
|
|
218
|
+
} finally {
|
|
219
|
+
try {
|
|
220
|
+
reader.releaseLock();
|
|
221
|
+
} catch {}
|
|
222
|
+
}
|
|
223
|
+
}
|
package/src/mood.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const MOODS = [
|
|
2
|
+
"angry",
|
|
3
|
+
"generous",
|
|
4
|
+
"paranoid",
|
|
5
|
+
"victorious",
|
|
6
|
+
"exhausted",
|
|
7
|
+
"manic",
|
|
8
|
+
] as const;
|
|
9
|
+
|
|
10
|
+
export type Mood = (typeof MOODS)[number];
|
|
11
|
+
|
|
12
|
+
export function pickMood(): Mood {
|
|
13
|
+
const i = Math.floor(Math.random() * MOODS.length);
|
|
14
|
+
return MOODS[i] ?? "manic";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function moodLine(mood: Mood): string {
|
|
18
|
+
return `\n\n---\n\nToday's Drexler mood: **${mood}**. All responses colored by this mood. Stay in character; let the mood tilt word choice and energy without becoming a different persona.`;
|
|
19
|
+
}
|