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/persona.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import type { PersonaData } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
const GREETINGS_HEADING = "### Greetings & Session Openers";
|
|
5
|
+
|
|
6
|
+
export function extractGreetings(md: string): string[] {
|
|
7
|
+
const lines = md.split("\n");
|
|
8
|
+
const out: string[] = [];
|
|
9
|
+
let inSection = false;
|
|
10
|
+
for (const line of lines) {
|
|
11
|
+
if (line.trim() === GREETINGS_HEADING) {
|
|
12
|
+
inSection = true;
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
if (inSection && /^#{1,6}\s/.test(line.trim())) break;
|
|
16
|
+
if (inSection) {
|
|
17
|
+
const m = line.match(/^-\s+"(.+)"\s*$/);
|
|
18
|
+
if (m && m[1]) out.push(m[1]);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loadPersona(path: string): Promise<PersonaData> {
|
|
25
|
+
let raw: string;
|
|
26
|
+
try {
|
|
27
|
+
raw = await readFile(path, "utf-8");
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
30
|
+
throw new Error(`Failed to load persona file at ${path}: ${msg}`);
|
|
31
|
+
}
|
|
32
|
+
const greetings = extractGreetings(raw);
|
|
33
|
+
if (greetings.length === 0) {
|
|
34
|
+
greetings.push("Drexler here. Get to point.");
|
|
35
|
+
}
|
|
36
|
+
return { systemPrompt: raw, greetings };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function pickGreeting(greetings: string[]): string {
|
|
40
|
+
if (greetings.length === 0) return "Drexler here. Get to point.";
|
|
41
|
+
const i = Math.floor(Math.random() * greetings.length);
|
|
42
|
+
return greetings[i] ?? greetings[0]!;
|
|
43
|
+
}
|
package/src/renderer.ts
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { highlight } from "cli-highlight";
|
|
3
|
+
import { marked } from "marked";
|
|
4
|
+
import { markedTerminal } from "marked-terminal";
|
|
5
|
+
import { THINKING_LINES, WITTICISMS } from "./sayings.ts";
|
|
6
|
+
import { STARTUP_TIPS } from "./startupTips.ts";
|
|
7
|
+
import { buildChalkColors, getActiveTheme } from "./ui/themes.ts";
|
|
8
|
+
|
|
9
|
+
export function getColors() {
|
|
10
|
+
return buildChalkColors(getActiveTheme());
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function highlightCodeBlock(code: string, lang: string | undefined): string {
|
|
14
|
+
let body: string;
|
|
15
|
+
try {
|
|
16
|
+
body = lang
|
|
17
|
+
? highlight(code, { language: lang, ignoreIllegals: true })
|
|
18
|
+
: highlight(code, { ignoreIllegals: true });
|
|
19
|
+
} catch {
|
|
20
|
+
body = chalk.gray(code);
|
|
21
|
+
}
|
|
22
|
+
const c = getColors();
|
|
23
|
+
const tag = c.apolloDim(lang ? `[${lang.toLowerCase()}]` : "[code]");
|
|
24
|
+
return `${tag}\n${body}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let markedThemeApplied = false;
|
|
28
|
+
function applyMarkedTheme(): void {
|
|
29
|
+
const c = getColors();
|
|
30
|
+
marked.use(
|
|
31
|
+
markedTerminal({
|
|
32
|
+
code: ((code: string, lang?: string) => highlightCodeBlock(code, lang)) as never,
|
|
33
|
+
blockquote: c.dim.italic,
|
|
34
|
+
heading: c.apolloLight.bold,
|
|
35
|
+
hr: c.apolloDim,
|
|
36
|
+
listitem: c.text,
|
|
37
|
+
strong: chalk.bold,
|
|
38
|
+
em: chalk.italic,
|
|
39
|
+
codespan: chalk.gray.bgBlackBright,
|
|
40
|
+
link: c.apolloLight.underline,
|
|
41
|
+
}) as never,
|
|
42
|
+
);
|
|
43
|
+
markedThemeApplied = true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Re-apply markdown theme when active theme changes.
|
|
47
|
+
export function resetMarkedTheme(): void {
|
|
48
|
+
markedThemeApplied = false;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const BANNER_LINES = [
|
|
52
|
+
" ██████╗ ██████╗ ███████╗██╗ ██╗██╗ ███████╗██████╗ ",
|
|
53
|
+
" ██╔══██╗ ██╔══██╗ ██╔════╝╚██╗██╔╝██║ ██╔════╝██╔══██╗",
|
|
54
|
+
" ██║ ██║ ██████╔╝ █████╗ ╚███╔╝ ██║ █████╗ ██████╔╝",
|
|
55
|
+
" ██║ ██║ ██╔══██╗ ██╔══╝ ██╔██╗ ██║ ██╔══╝ ██╔══██╗",
|
|
56
|
+
" ██████╔╝ ██║ ██║ ███████╗██╔╝ ██╗███████╗███████╗██║ ██║",
|
|
57
|
+
" ╚═════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚═╝ ╚═╝",
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const MASCOT_LINES = [
|
|
61
|
+
" ╔════╗ ",
|
|
62
|
+
" ╔════╩════╩════╗",
|
|
63
|
+
" ║ \\__ __/ ║",
|
|
64
|
+
" ║ ◆ ◆ ║",
|
|
65
|
+
" ║ ╔════╗ ║",
|
|
66
|
+
" ║ ║ $$ ║ ║",
|
|
67
|
+
" ╚════╩════╩════╝",
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
71
|
+
|
|
72
|
+
const BOX_WIDTH = 64;
|
|
73
|
+
|
|
74
|
+
export type LayoutMode = "wide" | "narrow" | "very-narrow";
|
|
75
|
+
export const WIDE_MIN = 80;
|
|
76
|
+
export const NARROW_MIN = 60;
|
|
77
|
+
|
|
78
|
+
export function pickLayout(cols: number): LayoutMode {
|
|
79
|
+
if (cols >= WIDE_MIN) return "wide";
|
|
80
|
+
if (cols >= NARROW_MIN) return "narrow";
|
|
81
|
+
return "very-narrow";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function termCols(): number {
|
|
85
|
+
return process.stdout.columns ?? 80;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Smooth 3-stop RGB lerp: primaryDim → primary → primaryLight, evenly
|
|
89
|
+
// distributed across all banner rows so the gradient blends row-by-row.
|
|
90
|
+
function hexToRgb(hex: string): [number, number, number] {
|
|
91
|
+
const m = hex.match(/^#?([0-9a-f]{6})$/i);
|
|
92
|
+
if (!m) return [0, 0, 0];
|
|
93
|
+
const v = parseInt(m[1] as string, 16);
|
|
94
|
+
return [(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function rgbToHex(r: number, g: number, b: number): string {
|
|
98
|
+
const clamp = (n: number) =>
|
|
99
|
+
Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, "0");
|
|
100
|
+
return "#" + clamp(r) + clamp(g) + clamp(b);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function lerpHex(a: string, b: string, t: number): string {
|
|
104
|
+
const [r1, g1, b1] = hexToRgb(a);
|
|
105
|
+
const [r2, g2, b2] = hexToRgb(b);
|
|
106
|
+
return rgbToHex(r1 + (r2 - r1) * t, g1 + (g2 - g1) * t, b1 + (b2 - b1) * t);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function gradientHexAt(t: number): string {
|
|
110
|
+
// t in [0,1]; 3 stops: dim (0), mid (0.5), light (1).
|
|
111
|
+
const theme = getActiveTheme();
|
|
112
|
+
if (t <= 0.5) return lerpHex(theme.primaryDim, theme.primary, t * 2);
|
|
113
|
+
return lerpHex(theme.primary, theme.primaryLight, (t - 0.5) * 2);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function colorBannerLine(line: string, rowIndex: number, totalRows: number): string {
|
|
117
|
+
const theme = getActiveTheme();
|
|
118
|
+
if (theme.ansi) {
|
|
119
|
+
// Mono / no-color theme — skip gradient; just print the line.
|
|
120
|
+
return line;
|
|
121
|
+
}
|
|
122
|
+
const cols = line.length;
|
|
123
|
+
if (cols === 0) return line;
|
|
124
|
+
// Diagonal sweep: each character's t = (row + col) / (rows-1 + cols-1).
|
|
125
|
+
// Interpolates RGB per-glyph for a smooth blended look across both axes.
|
|
126
|
+
const denom = Math.max(1, totalRows - 1 + (cols - 1));
|
|
127
|
+
let out = "";
|
|
128
|
+
for (let col = 0; col < cols; col++) {
|
|
129
|
+
const t = (rowIndex + col) / denom;
|
|
130
|
+
out += chalk.hex(gradientHexAt(t))(line[col] as string);
|
|
131
|
+
}
|
|
132
|
+
return out;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function banner(): string {
|
|
136
|
+
const total = BANNER_LINES.length;
|
|
137
|
+
return BANNER_LINES.map((l, i) => colorBannerLine(l, i, total)).join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function typewriterBanner(delayMs = 60): Promise<void> {
|
|
141
|
+
const total = BANNER_LINES.length;
|
|
142
|
+
for (let i = 0; i < total; i++) {
|
|
143
|
+
const line = BANNER_LINES[i] ?? "";
|
|
144
|
+
process.stdout.write(colorBannerLine(line, i, total) + "\n");
|
|
145
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function tagline(): string {
|
|
150
|
+
return getColors().warning.italic(
|
|
151
|
+
" Corporate AI · OpenRouter · Hostile Takeover Edition",
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function tipsList(): string {
|
|
156
|
+
const c = getColors();
|
|
157
|
+
const header = c.dim("Tips for getting started:");
|
|
158
|
+
const lines = STARTUP_TIPS.map(
|
|
159
|
+
(t, i) => ` ${c.apollo(`${i + 1}.`)} ${c.dim(t)}`,
|
|
160
|
+
);
|
|
161
|
+
return [header, ...lines].join("\n");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function visibleLength(s: string): number {
|
|
165
|
+
return s.replace(/\x1b\[[0-9;]*m/g, "").length;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function padVisible(s: string, target: number): string {
|
|
169
|
+
const pad = Math.max(0, target - visibleLength(s));
|
|
170
|
+
return s + " ".repeat(pad);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const MASCOT_PAD = " "; // matches mascot column width
|
|
174
|
+
|
|
175
|
+
export function welcomeBox(greetingLine: string, cols?: number): string {
|
|
176
|
+
const mode = cols === undefined ? "wide" : pickLayout(cols);
|
|
177
|
+
const c = getColors();
|
|
178
|
+
const boldLight = c.apolloLight.bold;
|
|
179
|
+
const tipRows = [
|
|
180
|
+
c.apolloLight.bold("Tips for getting started"),
|
|
181
|
+
...STARTUP_TIPS.map((tip, idx) => `${c.apollo(`${idx + 1}.`)} ${c.dim(tip)}`),
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
if (mode === "very-narrow") {
|
|
185
|
+
const innerRows: string[] = [
|
|
186
|
+
boldLight("Welcome to"),
|
|
187
|
+
boldLight("Drexler International™"),
|
|
188
|
+
"",
|
|
189
|
+
c.apolloLight(greetingLine),
|
|
190
|
+
"",
|
|
191
|
+
...tipRows,
|
|
192
|
+
];
|
|
193
|
+
const widest = innerRows.reduce(
|
|
194
|
+
(max, r) => Math.max(max, visibleLength(r)),
|
|
195
|
+
0,
|
|
196
|
+
);
|
|
197
|
+
const innerWidth = Math.max(1, Math.min(widest, (cols ?? 80) - 2));
|
|
198
|
+
const top = c.apollo("╭" + "─".repeat(innerWidth + 2) + "╮");
|
|
199
|
+
const bot = c.apollo("╰" + "─".repeat(innerWidth + 2) + "╯");
|
|
200
|
+
const side = c.apollo("│");
|
|
201
|
+
const bordered = innerRows.map(
|
|
202
|
+
(r) => `${side} ${padVisible(r, innerWidth)} ${side}`,
|
|
203
|
+
);
|
|
204
|
+
return [top, ...bordered, bot].map((l) => " " + l).join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (mode === "narrow") {
|
|
208
|
+
const mascot = MASCOT_LINES.map((l) => c.apollo(l));
|
|
209
|
+
const text = [
|
|
210
|
+
boldLight("Welcome to"),
|
|
211
|
+
boldLight("Drexler International™"),
|
|
212
|
+
"",
|
|
213
|
+
c.apolloLight(greetingLine),
|
|
214
|
+
"",
|
|
215
|
+
...tipRows,
|
|
216
|
+
];
|
|
217
|
+
const innerRows: string[] = [...mascot, "", ...text];
|
|
218
|
+
const widest = innerRows.reduce(
|
|
219
|
+
(max, r) => Math.max(max, visibleLength(r)),
|
|
220
|
+
0,
|
|
221
|
+
);
|
|
222
|
+
const innerWidth = Math.max(1, Math.min(widest, (cols ?? 80) - 4));
|
|
223
|
+
const top = c.apollo("╭" + "─".repeat(innerWidth + 2) + "╮");
|
|
224
|
+
const bot = c.apollo("╰" + "─".repeat(innerWidth + 2) + "╯");
|
|
225
|
+
const side = c.apollo("│");
|
|
226
|
+
const bordered = innerRows.map(
|
|
227
|
+
(r) => `${side} ${padVisible(r, innerWidth)} ${side}`,
|
|
228
|
+
);
|
|
229
|
+
return [top, ...bordered, bot].map((l) => " " + l).join("\n");
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// wide (default): existing side-by-side layout
|
|
233
|
+
const left = MASCOT_LINES.map((l) => c.apollo(l));
|
|
234
|
+
const middle = [
|
|
235
|
+
"",
|
|
236
|
+
boldLight("Welcome to"),
|
|
237
|
+
boldLight("Drexler International™"),
|
|
238
|
+
"",
|
|
239
|
+
c.apolloLight(greetingLine),
|
|
240
|
+
"",
|
|
241
|
+
"",
|
|
242
|
+
];
|
|
243
|
+
const right = [...tipRows];
|
|
244
|
+
const totalRows = Math.max(left.length, middle.length, right.length);
|
|
245
|
+
while (left.length < totalRows) left.push(MASCOT_PAD);
|
|
246
|
+
while (middle.length < totalRows) middle.push("");
|
|
247
|
+
while (right.length < totalRows) right.push("");
|
|
248
|
+
|
|
249
|
+
// Build inner rows: mascot · greeting · divider · tips.
|
|
250
|
+
const innerRows: string[] = [];
|
|
251
|
+
const leftWidth = left.reduce((max, r) => Math.max(max, visibleLength(r)), 0);
|
|
252
|
+
const middleWidth = middle.reduce(
|
|
253
|
+
(max, r) => Math.max(max, visibleLength(r)),
|
|
254
|
+
0,
|
|
255
|
+
);
|
|
256
|
+
const rightWidth = right.reduce((max, r) => Math.max(max, visibleLength(r)), 0);
|
|
257
|
+
for (let i = 0; i < totalRows; i++) {
|
|
258
|
+
innerRows.push(
|
|
259
|
+
`${padVisible(left[i] ?? "", leftWidth)} ${
|
|
260
|
+
padVisible(middle[i] ?? "", middleWidth)
|
|
261
|
+
} ${c.apolloDim("│")} ${padVisible(right[i] ?? "", rightWidth)}`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const innerWidth = innerRows.reduce(
|
|
265
|
+
(max, r) => Math.max(max, visibleLength(r)),
|
|
266
|
+
0,
|
|
267
|
+
);
|
|
268
|
+
const top = c.apollo("╭" + "─".repeat(innerWidth + 2) + "╮");
|
|
269
|
+
const bot = c.apollo("╰" + "─".repeat(innerWidth + 2) + "╯");
|
|
270
|
+
const side = c.apollo("│");
|
|
271
|
+
const bordered = innerRows.map(
|
|
272
|
+
(r) => `${side} ${padVisible(r, innerWidth)} ${side}`,
|
|
273
|
+
);
|
|
274
|
+
return [top, ...bordered, bot].map((l) => " " + l).join("\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function infoLine(): string {
|
|
278
|
+
return getColors().dim(`/help for directives · Ctrl+C to adjourn`);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function statusLine(
|
|
282
|
+
msgCount: number,
|
|
283
|
+
mode?: LayoutMode,
|
|
284
|
+
): string {
|
|
285
|
+
const c = getColors();
|
|
286
|
+
const middle = c.dim(`${msgCount} message${msgCount === 1 ? "" : "s"}`);
|
|
287
|
+
if (mode === "very-narrow") {
|
|
288
|
+
return middle;
|
|
289
|
+
}
|
|
290
|
+
const w = WITTICISMS[Math.floor(Math.random() * WITTICISMS.length)] ?? "";
|
|
291
|
+
const right = c.dim.italic(`"${w}"`);
|
|
292
|
+
return `${middle} ${c.apolloDim("│")} ${right}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export function inputBoxTop(cols?: number): string {
|
|
296
|
+
const w =
|
|
297
|
+
cols === undefined
|
|
298
|
+
? BOX_WIDTH
|
|
299
|
+
: Math.max(20, Math.min(cols - 2, BOX_WIDTH));
|
|
300
|
+
return getColors().apollo("╭" + "─".repeat(w - 2) + "╮");
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function inputBoxBottom(cols?: number): string {
|
|
304
|
+
const w =
|
|
305
|
+
cols === undefined
|
|
306
|
+
? BOX_WIDTH
|
|
307
|
+
: Math.max(20, Math.min(cols - 2, BOX_WIDTH));
|
|
308
|
+
return getColors().apollo("╰" + "─".repeat(w - 2) + "╯");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function inputBoxHint(): string {
|
|
312
|
+
return getColors().dim(
|
|
313
|
+
" /help · /clear · /regenerate · /save · /exit",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function prompt(): string {
|
|
318
|
+
const c = getColors();
|
|
319
|
+
return c.apollo("│ ") + c.apolloLight.bold("❯ ");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
export function info(msg: string): string {
|
|
323
|
+
return getColors().dim(msg);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function error(msg: string): string {
|
|
327
|
+
return getColors().error(msg);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function warning(msg: string): string {
|
|
331
|
+
return getColors().warning(msg);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function dim(msg: string): string {
|
|
335
|
+
return getColors().dim(msg);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function separator(): string {
|
|
339
|
+
return getColors().apolloDim("─".repeat(BOX_WIDTH));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export function pickThinkingLine(): string {
|
|
343
|
+
return THINKING_LINES[Math.floor(Math.random() * THINKING_LINES.length)] ?? "Drexler thinking";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface Spinner {
|
|
347
|
+
stop: () => void;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function startSpinner(label?: string): Spinner {
|
|
351
|
+
const line = label ?? pickThinkingLine();
|
|
352
|
+
let i = 0;
|
|
353
|
+
const isTTY = process.stdout.isTTY === true;
|
|
354
|
+
if (!isTTY) {
|
|
355
|
+
process.stdout.write(getColors().apollo(`◆ ${line}…\n`));
|
|
356
|
+
return { stop: () => {} };
|
|
357
|
+
}
|
|
358
|
+
process.stdout.write("\x1b[?25l");
|
|
359
|
+
const render = () => {
|
|
360
|
+
const frame = SPINNER_FRAMES[i++ % SPINNER_FRAMES.length] ?? "·";
|
|
361
|
+
const c = getColors();
|
|
362
|
+
process.stdout.write(`\r${c.apollo(frame)} ${c.dim(line + "…")} `);
|
|
363
|
+
};
|
|
364
|
+
render();
|
|
365
|
+
const timer = setInterval(render, 80);
|
|
366
|
+
return {
|
|
367
|
+
stop: () => {
|
|
368
|
+
clearInterval(timer);
|
|
369
|
+
process.stdout.write("\r\x1b[2K\x1b[?25h");
|
|
370
|
+
},
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface AccentBarWriter {
|
|
375
|
+
write: (chunk: string) => void;
|
|
376
|
+
end: () => void;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
export function createAccentBarWriter(): AccentBarWriter {
|
|
380
|
+
let atLineStart = true;
|
|
381
|
+
let started = false;
|
|
382
|
+
return {
|
|
383
|
+
write(chunk: string) {
|
|
384
|
+
if (!chunk) return;
|
|
385
|
+
let buf = "";
|
|
386
|
+
const c = getColors();
|
|
387
|
+
for (const ch of chunk) {
|
|
388
|
+
if (atLineStart) {
|
|
389
|
+
buf += c.apollo("│ ");
|
|
390
|
+
atLineStart = false;
|
|
391
|
+
started = true;
|
|
392
|
+
}
|
|
393
|
+
buf += ch === "\n" ? "" : c.text(ch);
|
|
394
|
+
if (ch === "\n") {
|
|
395
|
+
buf += "\n";
|
|
396
|
+
atLineStart = true;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
process.stdout.write(buf);
|
|
400
|
+
},
|
|
401
|
+
end() {
|
|
402
|
+
if (started && !atLineStart) process.stdout.write("\n");
|
|
403
|
+
atLineStart = true;
|
|
404
|
+
started = false;
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function renderMarkdown(md: string): string {
|
|
410
|
+
if (!markedThemeApplied) applyMarkedTheme();
|
|
411
|
+
return String(marked.parse(md, { async: false }));
|
|
412
|
+
}
|
package/src/repl.ts
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import * as readline from "node:readline";
|
|
2
|
+
import {
|
|
3
|
+
COMMAND_PALETTE,
|
|
4
|
+
dispatch,
|
|
5
|
+
isSlash,
|
|
6
|
+
type CommandAction,
|
|
7
|
+
} from "./commands.ts";
|
|
8
|
+
import type { Conversation } from "./conversation.ts";
|
|
9
|
+
import { streamChat, type FetchFn } from "./llm.ts";
|
|
10
|
+
import type { Message } from "./types.ts";
|
|
11
|
+
import {
|
|
12
|
+
createAccentBarWriter,
|
|
13
|
+
dim,
|
|
14
|
+
error,
|
|
15
|
+
info,
|
|
16
|
+
inputBoxBottom,
|
|
17
|
+
inputBoxHint,
|
|
18
|
+
inputBoxTop,
|
|
19
|
+
pickLayout,
|
|
20
|
+
prompt as styledPrompt,
|
|
21
|
+
startSpinner,
|
|
22
|
+
statusLine,
|
|
23
|
+
termCols,
|
|
24
|
+
} from "./renderer.ts";
|
|
25
|
+
import {
|
|
26
|
+
DRIFT_REMINDER,
|
|
27
|
+
EMPTY_NUDGE,
|
|
28
|
+
REMINDER_INTERVAL,
|
|
29
|
+
SIGINT_MSG,
|
|
30
|
+
STREAM_ERROR,
|
|
31
|
+
} from "./sayings.ts";
|
|
32
|
+
import { MODEL_FALLBACK, MODEL_PRIMARY, type Config } from "./types.ts";
|
|
33
|
+
|
|
34
|
+
const SLASH_COMMANDS = COMMAND_PALETTE.map((c) => c.name);
|
|
35
|
+
|
|
36
|
+
export interface ReplDeps {
|
|
37
|
+
conversation: Conversation;
|
|
38
|
+
config: Config;
|
|
39
|
+
fetchFn?: FetchFn;
|
|
40
|
+
print: (s: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function pickFallback(currentModel: string): string {
|
|
44
|
+
return currentModel === MODEL_PRIMARY ? MODEL_FALLBACK : MODEL_PRIMARY;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildMessagesWithReminder(conv: Conversation): Message[] {
|
|
48
|
+
const snap = conv.snapshot();
|
|
49
|
+
const turns = conv.userTurns;
|
|
50
|
+
if (turns > 0 && turns % REMINDER_INTERVAL === 0) {
|
|
51
|
+
return [...snap, { role: "system", content: DRIFT_REMINDER }];
|
|
52
|
+
}
|
|
53
|
+
return snap;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Confusable letters that look like Latin "I" — fold to ASCII before regex
|
|
57
|
+
// so detection isn't bypassed by Cyrillic І, Turkish İ, fullwidth I, etc.
|
|
58
|
+
const I_CONFUSABLES_RE = /[ІіİıIℐ]/g;
|
|
59
|
+
|
|
60
|
+
export function detectPersonaDrift(content: string): boolean {
|
|
61
|
+
const noCode = content
|
|
62
|
+
.replace(/```[\s\S]*?```/g, "")
|
|
63
|
+
.replace(/`[^`]*`/g, "");
|
|
64
|
+
const folded = noCode.normalize("NFKC").replace(I_CONFUSABLES_RE, "I");
|
|
65
|
+
return /\bI\b|\bI'm\b|\bI'll\b|\bI've\b|\bI'd\b/.test(folded);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface KeypressKey {
|
|
69
|
+
name?: string;
|
|
70
|
+
}
|
|
71
|
+
type KeypressListener = (str: string | undefined, key: KeypressKey) => void;
|
|
72
|
+
|
|
73
|
+
async function streamFromHistory(deps: ReplDeps): Promise<void> {
|
|
74
|
+
const spinner = startSpinner();
|
|
75
|
+
let firstToken = true;
|
|
76
|
+
const accent = createAccentBarWriter();
|
|
77
|
+
const abort = new AbortController();
|
|
78
|
+
let cancelled = false;
|
|
79
|
+
|
|
80
|
+
let escListener: KeypressListener | null = null;
|
|
81
|
+
if (process.stdin.isTTY) {
|
|
82
|
+
escListener = (_str, key) => {
|
|
83
|
+
if (key?.name === "escape") {
|
|
84
|
+
cancelled = true;
|
|
85
|
+
abort.abort();
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
process.stdin.on("keypress", escListener);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const onToken = (t: string) => {
|
|
92
|
+
if (firstToken) {
|
|
93
|
+
spinner.stop();
|
|
94
|
+
firstToken = false;
|
|
95
|
+
}
|
|
96
|
+
accent.write(t);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
let result;
|
|
100
|
+
try {
|
|
101
|
+
result = await streamChat({
|
|
102
|
+
apiKey: deps.config.apiKey,
|
|
103
|
+
model: deps.config.model,
|
|
104
|
+
fallbackModel: pickFallback(deps.config.model),
|
|
105
|
+
messages: buildMessagesWithReminder(deps.conversation),
|
|
106
|
+
onToken,
|
|
107
|
+
signal: abort.signal,
|
|
108
|
+
fetchFn: deps.fetchFn,
|
|
109
|
+
});
|
|
110
|
+
} finally {
|
|
111
|
+
if (escListener) process.stdin.off("keypress", escListener);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (firstToken) spinner.stop();
|
|
115
|
+
accent.end();
|
|
116
|
+
|
|
117
|
+
if (result.content) {
|
|
118
|
+
deps.conversation.push("assistant", result.content);
|
|
119
|
+
}
|
|
120
|
+
if (result.ok) {
|
|
121
|
+
if (result.fellBack) {
|
|
122
|
+
deps.print(info(`(fell back to ${result.modelUsed})`));
|
|
123
|
+
}
|
|
124
|
+
if (detectPersonaDrift(result.content)) {
|
|
125
|
+
deps.print(dim("(persona drift detected — model used 'I')"));
|
|
126
|
+
}
|
|
127
|
+
} else if (cancelled) {
|
|
128
|
+
deps.print(dim("(cancelled — Drexler taking lunch)"));
|
|
129
|
+
} else if (result.interrupted) {
|
|
130
|
+
deps.print(dim("(stream interrupted — partial response saved)"));
|
|
131
|
+
} else {
|
|
132
|
+
const detail = result.error ? ` [${result.error}]` : "";
|
|
133
|
+
deps.print(error(`${STREAM_ERROR}${detail}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function handleLine(
|
|
138
|
+
raw: string,
|
|
139
|
+
deps: ReplDeps,
|
|
140
|
+
): Promise<CommandAction> {
|
|
141
|
+
const line = raw.trim();
|
|
142
|
+
|
|
143
|
+
if (line === "") {
|
|
144
|
+
deps.print(EMPTY_NUDGE);
|
|
145
|
+
return { type: "continue" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (isSlash(line)) {
|
|
149
|
+
const action = dispatch(line, {
|
|
150
|
+
conversation: deps.conversation,
|
|
151
|
+
config: deps.config,
|
|
152
|
+
print: deps.print,
|
|
153
|
+
});
|
|
154
|
+
if (action.type === "regenerate") {
|
|
155
|
+
await streamFromHistory(deps);
|
|
156
|
+
return { type: "continue" };
|
|
157
|
+
}
|
|
158
|
+
return action;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
deps.conversation.push("user", line);
|
|
162
|
+
await streamFromHistory(deps);
|
|
163
|
+
return { type: "continue" };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function slashCompleter(line: string): [string[], string] {
|
|
167
|
+
if (!line.startsWith("/")) return [[], line];
|
|
168
|
+
const hits = SLASH_COMMANDS.filter((c) => c.startsWith(line));
|
|
169
|
+
return [hits.length ? hits : SLASH_COMMANDS, line];
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function printPromptHeader(deps: ReplDeps): void {
|
|
173
|
+
const cols = termCols();
|
|
174
|
+
const mode = pickLayout(cols);
|
|
175
|
+
console.log("");
|
|
176
|
+
console.log(
|
|
177
|
+
mode === "very-narrow"
|
|
178
|
+
? statusLine(deps.conversation.length, mode)
|
|
179
|
+
: statusLine(deps.conversation.length),
|
|
180
|
+
);
|
|
181
|
+
console.log(inputBoxTop(cols));
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function printPromptFooter(): void {
|
|
185
|
+
const cols = termCols();
|
|
186
|
+
console.log(inputBoxBottom(cols));
|
|
187
|
+
console.log(inputBoxHint());
|
|
188
|
+
console.log("");
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function startRepl(deps: ReplDeps): Promise<void> {
|
|
192
|
+
const rl = readline.createInterface({
|
|
193
|
+
input: process.stdin,
|
|
194
|
+
output: process.stdout,
|
|
195
|
+
terminal: true,
|
|
196
|
+
prompt: styledPrompt(),
|
|
197
|
+
completer: slashCompleter,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let exiting = false;
|
|
201
|
+
const cleanExit = (msg?: string): never => {
|
|
202
|
+
exiting = true;
|
|
203
|
+
if (msg) console.log("\n" + msg);
|
|
204
|
+
try {
|
|
205
|
+
rl.close();
|
|
206
|
+
} catch {}
|
|
207
|
+
process.exit(0);
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
rl.on("SIGINT", () => cleanExit(SIGINT_MSG));
|
|
211
|
+
|
|
212
|
+
printPromptHeader(deps);
|
|
213
|
+
rl.prompt();
|
|
214
|
+
for await (const line of rl) {
|
|
215
|
+
printPromptFooter();
|
|
216
|
+
const action = await handleLine(line, deps);
|
|
217
|
+
if (action.type === "exit") {
|
|
218
|
+
cleanExit(action.message ?? SIGINT_MSG);
|
|
219
|
+
}
|
|
220
|
+
if (!exiting) {
|
|
221
|
+
printPromptHeader(deps);
|
|
222
|
+
rl.prompt();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|