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/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
+ }