@sivori/quixotic 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/art/banner.txt +41 -0
- package/index.js +245 -0
- package/package.json +32 -0
package/art/banner.txt
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
2
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
3
|
+
⠠⠀⠄⠀⠤⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
4
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡧⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
5
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
6
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
7
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
8
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
9
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢷⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
10
|
+
⠀⠄⢀⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
11
|
+
⠀⠠⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
12
|
+
⠀⠡⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
13
|
+
⠈⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
14
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
15
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡆⠀⢀⣤⡆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
16
|
+
⠀⠀⠀⠀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⢀⠿⣿⣇⡼⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
17
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣿⣧⣼⣯⣼⡟⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
18
|
+
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣼⣿⣀⣟⠛⢽⡃⡀⠀⠀⠀⠀⢀⣠⣤⣤⣤⣤⣤⣤⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
19
|
+
⡀⠀⠀⠀⢀⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣨⣿⣿⣿⢰⡀⣞⠋⠁⢀⠀⠀⣰⣿⣿⣿⣿⣿⣿⣿⣿⣟⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
20
|
+
⣼⣦⡠⡛⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⢠⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣷⡌⠑⢶⣾⡀⢠⣿⣿⣿⣿⣿⣿⠟⢫⡿⠉⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
21
|
+
⠉⠛⠳⢧⣤⠂⠀⠀⠀⠀⠀⠀⣾⣷⣿⢠⠓⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠛⠿⠛⣿⣿⣿⣷⣤⣸⣿⢧⣼⣿⣿⣿⣯⡁⠀⠀⢨⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
22
|
+
⣤⠀⠀⠀⢹⣧⡀⠀⠀⠀⠀⢀⣿⣿⣧⣾⣋⡂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⣿⣿⡖⣿⡟⢏⠋⠠⢺⣿⣿⣿⣿⣷⡄⠀⠁⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
23
|
+
⣿⣷⡀⠀⠀⢘⣿⡆⠀⠀⢺⣿⠿⠿⠛⢻⣿⣁⠀⠐⣾⣷⣄⡀⣀⣠⣤⣄⣠⣴⣿⣏⢿⡇⢿⣾⣿⣦⣴⣿⣿⣿⣿⣿⡎⡇⠀⠀⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
24
|
+
⠹⣿⡿⠿⠦⣄⠓⢙⡷⢂⣿⠃⠀⢀⡁⠈⠋⢠⠀⢀⡈⠋⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣾⣧⠘⣿⣿⣿⣿⣿⣿⣿⣿⣿⣇⠁⠀⠀⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
25
|
+
⠈⠀⢐⡀⠀⠀⠙⠆⠠⠁⣿⡆⢀⣿⣥⡆⠀⣀⡱⣈⣿⣖⠈⢿⣿⣿⣿⣿⣿⡟⢻⣿⣿⣿⣶⣿⣿⣿⣿⣿⣿⣿⣿⣻⣯⠉⣽⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
26
|
+
⡄⠀⠀⠁⠉⣀⢠⣞⣼⠫⣿⡇⠀⣹⣿⣋⠉⠈⠛⠍⢽⣿⠃⣽⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣯⡙⢿⣍⣡⡿⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
27
|
+
⠀⠀⠀⠀⠀⠀⢸⣿⣿⣿⣿⣿⣾⣿⣿⡷⢂⢀⣠⢤⣤⣽⣛⣿⢿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⡄⠙⠛⠓⠇⢠⠂⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
28
|
+
⢲⡀⠀⠀⠀⠀⢸⠿⣿⣿⣿⣿⣻⡿⢏⣴⣿⣿⣿⣃⢀⠉⢝⢻⣧⡈⣹⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣷⣦⡀⠀⠄⠈⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
29
|
+
⣿⣯⣤⣤⡀⢀⡼⠀⣿⣿⣿⣿⣿⣥⣾⣿⣿⣿⣿⣿⣿⣿⣎⡇⢻⡿⠟⠛⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡇⠐⣷⢰⣤⠄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀
|
|
30
|
+
⠾⠿⠅⠀⣴⡟⠁⠀⣻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠟⢋⣀⣿⣦⣀⠘⢿⣿⠋⠉⠛⣿⣿⣿⣿⡿⣿⣿⣿⣿⡫⣤⢿⣶⡽⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡄
|
|
31
|
+
⡐⢀⠀⠀⢿⡇⠀⢸⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣩⡅⣣⠉⣿⣿⣿⠉⠀⠀⣿⣿⣿⡿⠁⠈⠛⠻⣿⣻⣌⡂⢻⣷⠀⠀⠀⠀⠀⠀⠀⠀⢰⣶⣬⣿
|
|
32
|
+
⠀⠈⠁⠀⠘⠃⠀⠀⢻⣿⣿⣿⣿⠻⢿⣿⣿⣿⣿⣿⡿⣟⣿⣿⣿⣿⠇⠀⠈⠙⠿⣷⣄⠀⠉⠉⠀⠀⠀⠀⠀⠀⠀⠙⠻⢿⣷⣍⠀⠀⠀⠀⠀⣤⣴⣄⣸⣿⣿⣿
|
|
33
|
+
⣐⣂⣤⣄⢀⡄⠀⣠⣾⣿⡿⠿⠟⠀⠀⠈⢿⣿⣿⣿⣶⣿⢻⣿⣿⣿⠀⠀⠀⠀⠀⠈⢿⣆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⣿⣿⣷⣦⠀⠀⢰⣿⣿⣿⣿⣿⣿⣿
|
|
34
|
+
⣭⣥⣼⣭⡷⣤⡀⠻⠛⢹⣿⣆⠀⠀⠀⠀⠈⠻⢯⣳⡙⣿⣟⢻⡥⠋⠀⠀⠀⠀⠀⠀⢀⡟⠁⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠿⠛⠁⠹⣿⡇⠀⣼⣿⣿⣿⣿⣿⣿⣿
|
|
35
|
+
⣿⣿⣿⣶⣶⣾⣛⣛⣃⡌⣿⣿⠀⠀⠀⠀⠀⠀⢸⡿⠃⠹⣿⡄⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⡇⠀⠀⠀⠀⣰⣾⣰⣿⠟⠁⠀⠀⠀⠀⢻⡇⢉⠀⡠⠀⠈⠙⠻⢿⣿
|
|
36
|
+
⠿⢿⣿⣛⡙⠩⠽⢭⠽⣟⠛⠿⠀⠀⠀⠀⢀⣴⣾⠃⠀⡀⢹⣇⢀⣀⣴⡒⠋⠀⠀⠂⠀⠀⠀⠀⠀⠀⢀⡮⠉⠀⠀⠀⠀⠀⠀⠀⠀⢸⣧⠀⠈⠳⣄⢠⣀⠀⠀⠈
|
|
37
|
+
⣴⣿⠷⡿⣧⣷⣦⣬⣴⣀⣄⣤⣀⠀⢀⡀⣿⠟⣉⠖⠋⠉⠀⢿⣣⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⡤⠐⠊⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠗⠀⠀⠈⠱⢿⡿⣤⡀
|
|
38
|
+
⣿⣿⣷⣶⣿⣷⣿⣯⣿⣿⣼⣷⣦⣤⣈⠃⠈⠉⠈⢀⣀⣄⣠⣤⣯⣤⡀⠀⣀⣤⣤⣄⣀⣠⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣿⡦⠀⠀⠀⠀⠀⠁⢺⣿
|
|
39
|
+
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣽⣿⠿⠀⢀⣬⣴⣷⣿⣟⣫⣭⣭⣿⣿⣿⣿⠟⠋⠁⠉⠁⠁⠀⠀⠀⠀⠀⠀⠀⠀⢀⣀⣠⠤⠄⠀⠀⠀⠉⠃⠀⠀⠀⠀⠀⠀⠪⣙
|
|
40
|
+
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠐⢿⡁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠨
|
|
41
|
+
⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⡿⡿⠛⠟⠉⠁⠀⠀⠀⠀⢀⣤⣴⣾⣿⣿⡿⠿⠻⠿⠿⠷⠿⠇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠠⠀⠂⠀⡀
|
package/index.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
3
|
+
import * as readline from "node:readline/promises";
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { stdin, stdout, stderr, env, argv, exit } from "node:process";
|
|
8
|
+
|
|
9
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
|
|
11
|
+
const MODEL = env.MODEL || "claude-sonnet-4-6";
|
|
12
|
+
|
|
13
|
+
if (!env.ANTHROPIC_API_KEY) {
|
|
14
|
+
stderr.write("quixotic: ANTHROPIC_API_KEY is not set in the environment.\n");
|
|
15
|
+
exit(1);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const client = new Anthropic();
|
|
19
|
+
|
|
20
|
+
// ── ANSI styling ──────────────────────────────────────────────────────────────
|
|
21
|
+
const ansi = {
|
|
22
|
+
reset: "\x1b[0m",
|
|
23
|
+
bold: "\x1b[1m",
|
|
24
|
+
dim: "\x1b[2m",
|
|
25
|
+
italic: "\x1b[3m",
|
|
26
|
+
cyan: "\x1b[36m",
|
|
27
|
+
yellow: "\x1b[33m",
|
|
28
|
+
gray: "\x1b[90m",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// ── Banner art ────────────────────────────────────────────────────────────────
|
|
32
|
+
// art/banner.txt is generated from a public-domain Gustave Doré etching of
|
|
33
|
+
// Don Quixote and Sancho Panza on horseback (Quixote, 1863), converted with
|
|
34
|
+
// `ascii-image-converter -W 60 -b -n` (negative + braille at 60 cols). The
|
|
35
|
+
// converter is a build-time dependency only — the .txt file is the asset that
|
|
36
|
+
// ships with the project, so end users don't need anything installed.
|
|
37
|
+
function loadBanner() {
|
|
38
|
+
try {
|
|
39
|
+
return readFileSync(join(SCRIPT_DIR, "art", "banner.txt"), "utf8")
|
|
40
|
+
.replace(/\n+$/, "");
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const BANNER_ART = loadBanner();
|
|
47
|
+
|
|
48
|
+
// ── System prompts: the soul of each character ────────────────────────────────
|
|
49
|
+
const QUIXOTE_SYSTEM = `You are Don Quixote de la Mancha, the noble knight-errant of Cervantes' immortal novel. You speak with grandeur, conviction, and unshakable idealism. To you, every problem is a quest, every obstacle an enchanter's curse, every choice a matter of honor and the love of Dulcinea del Toboso. You see giants where others see windmills, and you would not have it otherwise.
|
|
50
|
+
|
|
51
|
+
Your counsel is bold, romantic, and sincere — never cynical, never small. Speak in a slightly archaic cadence, as a knight of the old romances would. Address the questioner directly, as a fellow traveler whom you have just met upon the road.
|
|
52
|
+
|
|
53
|
+
Keep your answer to 2–3 short paragraphs. Do not break character. Do not begin with hedges or pleasantries — speak with the certainty of one who has sworn an oath.`;
|
|
54
|
+
|
|
55
|
+
const SANCHO_SYSTEM = `You are Sancho Panza, faithful squire to Don Quixote and an earthy peasant of La Mancha. You are stuffed full of folk wisdom and proverbs ("there's no sauce in the world like hunger", "tell me what company you keep and I'll tell you who you are", "better a sparrow in the hand than a vulture on the wing"). You love food, your donkey Dapple, your wife Teresa, and a soft place to sleep.
|
|
56
|
+
|
|
57
|
+
Your master has just delivered his grand counsel — and now you must offer the practical, grounded view. Gently puncture his flights of fancy where he is dreaming, agree where he is right, and always speak plain truth to the questioner. You MUST work at least one proverb naturally into your reply. You love and respect your master, but you also know him.
|
|
58
|
+
|
|
59
|
+
Speak directly to the questioner, not to Don Quixote himself. Keep your answer to 2–3 short paragraphs. Do not break character.`;
|
|
60
|
+
|
|
61
|
+
// ── One streamed reply from one character ─────────────────────────────────────
|
|
62
|
+
async function streamCharacter({ system, messages, color, name, maxTokens = 700 }) {
|
|
63
|
+
stdout.write(`\n${color}${ansi.bold}${name}${ansi.reset}\n${color}`);
|
|
64
|
+
const stream = client.messages
|
|
65
|
+
.stream({
|
|
66
|
+
model: MODEL,
|
|
67
|
+
max_tokens: maxTokens,
|
|
68
|
+
system,
|
|
69
|
+
messages,
|
|
70
|
+
})
|
|
71
|
+
.on("text", (delta) => {
|
|
72
|
+
stdout.write(delta);
|
|
73
|
+
});
|
|
74
|
+
const text = await stream.finalText();
|
|
75
|
+
stdout.write(`${ansi.reset}\n`);
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Format prior exchanges as context for conversation memory ─────────────────
|
|
80
|
+
function formatHistory(history) {
|
|
81
|
+
if (!history.length) return "";
|
|
82
|
+
const entries = history
|
|
83
|
+
.map(
|
|
84
|
+
(h) =>
|
|
85
|
+
`Traveler: "${h.question}"\n` +
|
|
86
|
+
`Don Quixote: "${h.quixoteReply}"\n` +
|
|
87
|
+
`Sancho Panza: "${h.sanchoReply}"`,
|
|
88
|
+
)
|
|
89
|
+
.join("\n---\n");
|
|
90
|
+
return (
|
|
91
|
+
"Earlier on this journey, you have counseled this same traveler:\n---\n" +
|
|
92
|
+
entries +
|
|
93
|
+
"\n---\n\n"
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── A full exchange, optionally multi-round ───────────────────────────────────
|
|
98
|
+
async function ask(question, { rounds = 1, history = [] } = {}) {
|
|
99
|
+
const ctx = formatHistory(history);
|
|
100
|
+
const transcript = [];
|
|
101
|
+
|
|
102
|
+
for (let round = 0; round < rounds; round++) {
|
|
103
|
+
if (round > 0) {
|
|
104
|
+
stdout.write(
|
|
105
|
+
`\n${ansi.gray}${ansi.dim}${"─".repeat(40)} round ${round + 1} ${"─".repeat(10)}${ansi.reset}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const dialogueSoFar = transcript
|
|
110
|
+
.map((t) => `${t.speaker}:\n"""\n${t.text}\n"""`)
|
|
111
|
+
.join("\n\n");
|
|
112
|
+
|
|
113
|
+
// ── Quixote ──────────────────────────────────────────────────────────────
|
|
114
|
+
let qPrompt = ctx;
|
|
115
|
+
if (round === 0) {
|
|
116
|
+
qPrompt += `A traveler upon the road asks you:\n\n"${question}"`;
|
|
117
|
+
} else {
|
|
118
|
+
qPrompt +=
|
|
119
|
+
`A traveler upon the road asked: "${question}"\n\n` +
|
|
120
|
+
`The dialogue so far:\n${dialogueSoFar}\n\n` +
|
|
121
|
+
`Your squire Sancho has spoken. Respond to his words — defend your ` +
|
|
122
|
+
`position, refine it, or concede where he speaks truth. Address the ` +
|
|
123
|
+
`traveler, not Sancho directly. Keep your response to 1 short paragraph.`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const quixoteReply = await streamCharacter({
|
|
127
|
+
system: QUIXOTE_SYSTEM,
|
|
128
|
+
messages: [{ role: "user", content: qPrompt }],
|
|
129
|
+
color: ansi.cyan,
|
|
130
|
+
name: "Don Quixote",
|
|
131
|
+
maxTokens: round === 0 ? 700 : 400,
|
|
132
|
+
});
|
|
133
|
+
transcript.push({ speaker: "Don Quixote", text: quixoteReply });
|
|
134
|
+
|
|
135
|
+
// ── Sancho ────────────────────────────────────────────────────────────────
|
|
136
|
+
const fullDialogue = transcript
|
|
137
|
+
.map((t) => `${t.speaker}:\n"""\n${t.text}\n"""`)
|
|
138
|
+
.join("\n\n");
|
|
139
|
+
|
|
140
|
+
let sPrompt =
|
|
141
|
+
ctx +
|
|
142
|
+
`A traveler upon the road has asked us both this question:\n\n` +
|
|
143
|
+
`"${question}"\n\n` +
|
|
144
|
+
`The dialogue so far:\n${fullDialogue}\n\n` +
|
|
145
|
+
`Now I must give my own counsel to the traveler.`;
|
|
146
|
+
if (round > 0) {
|
|
147
|
+
sPrompt +=
|
|
148
|
+
` I should respond to what my master just said. ` +
|
|
149
|
+
`Keep my response to 1 short paragraph.`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const sanchoReply = await streamCharacter({
|
|
153
|
+
system: SANCHO_SYSTEM,
|
|
154
|
+
messages: [{ role: "user", content: sPrompt }],
|
|
155
|
+
color: ansi.yellow,
|
|
156
|
+
name: "Sancho Panza",
|
|
157
|
+
maxTokens: round === 0 ? 700 : 400,
|
|
158
|
+
});
|
|
159
|
+
transcript.push({ speaker: "Sancho Panza", text: sanchoReply });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Return the final state for history — concatenate all rounds per character.
|
|
163
|
+
const quixoteParts = transcript.filter((t) => t.speaker === "Don Quixote");
|
|
164
|
+
const sanchoParts = transcript.filter((t) => t.speaker === "Sancho Panza");
|
|
165
|
+
return {
|
|
166
|
+
question,
|
|
167
|
+
quixoteReply: quixoteParts.map((t) => t.text).join("\n\n"),
|
|
168
|
+
sanchoReply: sanchoParts.map((t) => t.text).join("\n\n"),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Banner shown on startup ───────────────────────────────────────────────────
|
|
173
|
+
function banner() {
|
|
174
|
+
stdout.write("\n");
|
|
175
|
+
if (BANNER_ART) {
|
|
176
|
+
// Dim white reads as etched ink and stays neutral between the two
|
|
177
|
+
// speaking colors (cyan for Quixote, yellow for Sancho).
|
|
178
|
+
stdout.write(ansi.dim + BANNER_ART + ansi.reset + "\n");
|
|
179
|
+
} else {
|
|
180
|
+
stdout.write(
|
|
181
|
+
ansi.dim + " Don Quixote & Sancho Panza" + ansi.reset + "\n",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
stdout.write(
|
|
185
|
+
"\n" + ansi.gray + ansi.italic +
|
|
186
|
+
" Two advisors await thy question. (Ctrl+D to depart)" +
|
|
187
|
+
ansi.reset + "\n",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Parse CLI arguments ───────────────────────────────────────────────────────
|
|
192
|
+
function parseArgs() {
|
|
193
|
+
const args = argv.slice(2);
|
|
194
|
+
let rounds = 1;
|
|
195
|
+
const words = [];
|
|
196
|
+
for (let i = 0; i < args.length; i++) {
|
|
197
|
+
if (args[i] === "--rounds" && args[i + 1]) {
|
|
198
|
+
rounds = Math.max(1, parseInt(args[i + 1], 10) || 1);
|
|
199
|
+
i++;
|
|
200
|
+
} else {
|
|
201
|
+
words.push(args[i]);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { rounds, question: words.join(" ").trim() };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
208
|
+
async function main() {
|
|
209
|
+
const { rounds, question: oneShot } = parseArgs();
|
|
210
|
+
|
|
211
|
+
if (oneShot) {
|
|
212
|
+
banner();
|
|
213
|
+
await ask(oneShot, { rounds });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
banner();
|
|
218
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
219
|
+
rl.on("close", () => {
|
|
220
|
+
stdout.write(ansi.gray + "\nFarewell, traveler.\n" + ansi.reset);
|
|
221
|
+
exit(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const history = [];
|
|
225
|
+
|
|
226
|
+
// Loop until the user closes stdin (Ctrl+D) or hits Ctrl+C.
|
|
227
|
+
while (true) {
|
|
228
|
+
let q;
|
|
229
|
+
try {
|
|
230
|
+
q = (await rl.question(`\n${ansi.gray}? ${ansi.reset}`)).trim();
|
|
231
|
+
} catch {
|
|
232
|
+
// rl was closed mid-question — exit cleanly.
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (!q) continue;
|
|
236
|
+
try {
|
|
237
|
+
const exchange = await ask(q, { rounds, history: history.slice(-10) });
|
|
238
|
+
history.push(exchange);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
stderr.write(ansi.gray + `(${e.message})\n` + ansi.reset);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
main();
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sivori/quixotic",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Two desktop advisors — Don Quixote and Sancho Panza — in dialogue at your terminal.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"quixotic": "./index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node index.js"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@anthropic-ai/sdk": "^0.105.0"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"index.js",
|
|
17
|
+
"art"
|
|
18
|
+
],
|
|
19
|
+
"author": "Christopher Sivori",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/sivori/quixotic.git"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/sivori/quixotic#readme",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/sivori/quixotic/issues"
|
|
27
|
+
},
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
}
|
|
32
|
+
}
|