@desk2quant/cli 1.1.0 → 2.0.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/README.md +77 -9
- package/d2q.mjs +18 -19
- package/package.json +7 -3
- package/tui.mjs +466 -0
package/README.md
CHANGED
|
@@ -1,16 +1,14 @@
|
|
|
1
|
-
# Desk2Quant Quant Agent CLI
|
|
1
|
+
# Desk2Quant Quant Agent CLI v2
|
|
2
2
|
|
|
3
|
-
A terminal-
|
|
3
|
+
A terminal-native quant copilot for learning, rigorous problem solving, interview practice, adaptive assessment and project design.
|
|
4
4
|
|
|
5
5
|
## Requirements
|
|
6
6
|
|
|
7
7
|
- Node.js 20+
|
|
8
|
-
- A paid Desk2Quant purchase using the email you sign in with
|
|
8
|
+
- A paid Desk2Quant purchase using the same email you sign in with
|
|
9
9
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
|
-
Once the package is published:
|
|
13
|
-
|
|
14
12
|
```bash
|
|
15
13
|
npm install -g @desk2quant/cli
|
|
16
14
|
```
|
|
@@ -28,9 +26,67 @@ npm link
|
|
|
28
26
|
d2q login you@example.com
|
|
29
27
|
```
|
|
30
28
|
|
|
31
|
-
Desk2Quant emails
|
|
29
|
+
Desk2Quant emails a My Access sign-in message. For CLI authentication, copy the raw `https://desk2quant.com/my-access.html?email=...&tk=...` URL shown in the dedicated CLI box and paste it at `Magic link >`.
|
|
30
|
+
|
|
31
|
+
The CLI exchanges that temporary sign-in credential for a signed Quant Agent session. Model, Razorpay, Supabase and email-service secrets remain server-side.
|
|
32
32
|
|
|
33
|
-
##
|
|
33
|
+
## Launch the dedicated TUI
|
|
34
|
+
|
|
35
|
+
After sign-in, run:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
d2q
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
This opens the Quant Agent v2 terminal interface. Ask questions naturally; the agent automatically routes the request to the appropriate workflow.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
|
|
45
|
+
```text
|
|
46
|
+
Explain why gamma becomes concentrated near the strike as expiry approaches.
|
|
47
|
+
Derive the Black-Scholes PDE from a delta-hedged portfolio.
|
|
48
|
+
Interview me for an equity derivatives quant role.
|
|
49
|
+
Design a production-grade Heston calibration project.
|
|
50
|
+
Give me a probability drill focused on conditional expectation.
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## TUI controls
|
|
54
|
+
|
|
55
|
+
```text
|
|
56
|
+
/mode auto|learn|solve|practice|interview|project
|
|
57
|
+
/depth concise|standard|deep
|
|
58
|
+
/progress
|
|
59
|
+
/skills
|
|
60
|
+
/assess <skill>
|
|
61
|
+
/submit <assessment-id> <answer>
|
|
62
|
+
/context
|
|
63
|
+
/history
|
|
64
|
+
/sources
|
|
65
|
+
/new
|
|
66
|
+
/export
|
|
67
|
+
/clear
|
|
68
|
+
/help
|
|
69
|
+
/quit
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The default mode is `auto`, and the default answer depth is `deep`.
|
|
73
|
+
|
|
74
|
+
## Multi-turn context
|
|
75
|
+
|
|
76
|
+
The TUI keeps recent conversation context locally so follow-up questions can refer to the current discussion. For example:
|
|
77
|
+
|
|
78
|
+
```text
|
|
79
|
+
You: I am calibrating Heston to an SPX smile.
|
|
80
|
+
You: Why is rho unstable?
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
The second question is interpreted in the context of the Heston calibration discussion rather than as an unrelated definition of rho.
|
|
84
|
+
|
|
85
|
+
Local TUI history is stored under `~/.desk2quant/` with owner-only permissions where supported. `/new` starts a fresh conversation and `/export` writes the current transcript to a Markdown file in the working directory.
|
|
86
|
+
|
|
87
|
+
## One-shot commands remain supported
|
|
88
|
+
|
|
89
|
+
The v1-style commands still work for scripts and quick terminal usage:
|
|
34
90
|
|
|
35
91
|
```bash
|
|
36
92
|
d2q learn "Ito's lemma"
|
|
@@ -39,6 +95,7 @@ d2q practice "conditional probability"
|
|
|
39
95
|
d2q interview "quant research"
|
|
40
96
|
d2q project "Heston calibration"
|
|
41
97
|
d2q progress
|
|
98
|
+
d2q whoami
|
|
42
99
|
```
|
|
43
100
|
|
|
44
101
|
## Adaptive assessment
|
|
@@ -51,16 +108,27 @@ d2q skills
|
|
|
51
108
|
|
|
52
109
|
`theta` is a bounded latent ability estimate updated from graded assessments. It is not a percentage or percentile.
|
|
53
110
|
|
|
111
|
+
Supported assessment areas include probability, linear algebra, statistics, stochastic calculus, derivatives, fixed income, numerical methods, programming, risk and quant research.
|
|
112
|
+
|
|
54
113
|
## Private Desk2Quant retrieval
|
|
55
114
|
|
|
56
115
|
When the signed-in buyer has a Razorpay-verified entitlement to an indexed Desk2Quant product, relevant excerpts from that product can be retrieved server-side and used as grounding. The CLI cannot query the private corpus directly and no paid-book text is bundled with this package.
|
|
57
116
|
|
|
117
|
+
The TUI does not fabricate citations. `/sources` only displays source metadata when the backend actually returns it.
|
|
118
|
+
|
|
58
119
|
## Local credentials
|
|
59
120
|
|
|
60
|
-
The CLI stores only the signed Desk2Quant agent session in `~/.desk2quant/config.json`, created with owner-only permissions where supported.
|
|
121
|
+
The CLI stores only the signed Desk2Quant agent session in `~/.desk2quant/config.json`, created with owner-only permissions where supported.
|
|
122
|
+
|
|
123
|
+
To remove the local session:
|
|
61
124
|
|
|
62
125
|
```bash
|
|
63
126
|
d2q logout
|
|
64
127
|
```
|
|
65
128
|
|
|
66
|
-
|
|
129
|
+
## Security
|
|
130
|
+
|
|
131
|
+
- No Desk2Quant model/API secret is shipped in the npm package.
|
|
132
|
+
- Purchase entitlement is verified server-side.
|
|
133
|
+
- Authentication uses short-lived My Access credentials and a signed local agent session.
|
|
134
|
+
- Conversation history is local to the CLI unless included in a user request sent to the Desk2Quant backend for contextual answering.
|
package/d2q.mjs
CHANGED
|
@@ -17,8 +17,9 @@ import {
|
|
|
17
17
|
startAssessment,
|
|
18
18
|
submitAssessment
|
|
19
19
|
} from './engine.mjs';
|
|
20
|
+
import { startTui } from './tui.mjs';
|
|
20
21
|
|
|
21
|
-
const VERSION = '
|
|
22
|
+
const VERSION = '2.0.0';
|
|
22
23
|
|
|
23
24
|
function hasFlag(args, flag) { return args.includes(flag); }
|
|
24
25
|
function withoutFlags(args) { return args.filter(a => !a.startsWith('--')); }
|
|
@@ -37,13 +38,15 @@ async function login(email, rl, json = false) {
|
|
|
37
38
|
await requestLogin(email);
|
|
38
39
|
if (!json) {
|
|
39
40
|
console.log('A Desk2Quant sign-in link has been requested.');
|
|
40
|
-
console.log('Open your email
|
|
41
|
+
console.log('Open your email and copy the raw Desk2Quant URL shown in the "Using the Quant Agent CLI?" box.');
|
|
42
|
+
console.log('Paste that complete https://desk2quant.com/my-access.html?email=...&tk=... URL below.');
|
|
41
43
|
}
|
|
42
44
|
const link = (await rl.question('Magic link > ')).trim();
|
|
43
45
|
const result = await exchangeMagicLink(link);
|
|
44
46
|
if (json) return print({ success: true, email: result.config.email, tier: result.tier, expiresAt: result.expiresAt, progress: result.progress }, true);
|
|
45
47
|
console.log(`Signed in as ${result.config.email} (${result.tier}).`);
|
|
46
48
|
if (result.progress) console.log('\n' + formatProgress(result.progress));
|
|
49
|
+
console.log('\nRun `d2q` to open the dedicated Quant Agent interface.');
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
async function execute(command, rest, rl, json = false) {
|
|
@@ -92,6 +95,7 @@ async function execute(command, rest, rl, json = false) {
|
|
|
92
95
|
console.log(`\n${formatTerminalMath(result.abilityNote)}`);
|
|
93
96
|
return;
|
|
94
97
|
}
|
|
98
|
+
if (command === 'tui') return startTui();
|
|
95
99
|
if (COMMANDS.has(command)) {
|
|
96
100
|
const query = rest.join(' ').trim();
|
|
97
101
|
const result = await runCommand(command, query);
|
|
@@ -106,26 +110,21 @@ async function main() {
|
|
|
106
110
|
if (rawArgs.includes('--version') || rawArgs.includes('-v')) return console.log(`Desk2Quant Quant Agent CLI ${VERSION}`);
|
|
107
111
|
const json = hasFlag(rawArgs, '--json');
|
|
108
112
|
const args = withoutFlags(rawArgs);
|
|
109
|
-
const rl = readline.createInterface({ input, output });
|
|
110
|
-
|
|
111
|
-
try {
|
|
112
|
-
if (args.length) return await execute(args[0].toLowerCase(), args.slice(1), rl, json);
|
|
113
113
|
|
|
114
|
-
|
|
114
|
+
if (!args.length) {
|
|
115
115
|
const cfg = await loadConfig();
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (!line) continue;
|
|
122
|
-
if (['exit', 'quit'].includes(line.toLowerCase())) break;
|
|
123
|
-
const parts = line.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
|
|
124
|
-
const clean = parts.map(p => p.replace(/^"|"$/g, ''));
|
|
125
|
-
const command = String(clean.shift() || '').toLowerCase();
|
|
126
|
-
try { await execute(command, clean, rl, false); }
|
|
127
|
-
catch (err) { console.error(`Error: ${err.message}`); }
|
|
116
|
+
if (!cfg) {
|
|
117
|
+
console.log(`Desk2Quant Quant Agent CLI ${VERSION}`);
|
|
118
|
+
console.log('Not signed in.');
|
|
119
|
+
console.log('Run: d2q login <purchase-email>');
|
|
120
|
+
return;
|
|
128
121
|
}
|
|
122
|
+
return startTui();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const rl = readline.createInterface({ input, output });
|
|
126
|
+
try {
|
|
127
|
+
return await execute(args[0].toLowerCase(), args.slice(1), rl, json);
|
|
129
128
|
} finally {
|
|
130
129
|
rl.close();
|
|
131
130
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@desk2quant/cli",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Desk2Quant Quant Agent
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Desk2Quant terminal-native Quant Agent for aspiring and working quants",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"d2q": "d2q.mjs"
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"d2q.mjs",
|
|
11
11
|
"engine.mjs",
|
|
12
|
+
"tui.mjs",
|
|
12
13
|
"README.md"
|
|
13
14
|
],
|
|
14
15
|
"repository": {
|
|
@@ -30,8 +31,11 @@
|
|
|
30
31
|
"keywords": [
|
|
31
32
|
"quant",
|
|
32
33
|
"quantitative-finance",
|
|
34
|
+
"terminal-ui",
|
|
35
|
+
"tui",
|
|
33
36
|
"interview",
|
|
34
37
|
"desk2quant",
|
|
35
|
-
"cli"
|
|
38
|
+
"cli",
|
|
39
|
+
"quant-agent"
|
|
36
40
|
]
|
|
37
41
|
}
|
package/tui.mjs
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import readline from 'node:readline/promises';
|
|
5
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
6
|
+
import {
|
|
7
|
+
formatProgress,
|
|
8
|
+
formatSkills,
|
|
9
|
+
formatTerminalMath,
|
|
10
|
+
getProgress,
|
|
11
|
+
getSkills,
|
|
12
|
+
loadConfig,
|
|
13
|
+
runCommand,
|
|
14
|
+
startAssessment,
|
|
15
|
+
submitAssessment
|
|
16
|
+
} from './engine.mjs';
|
|
17
|
+
|
|
18
|
+
const VERSION = '2.0.0';
|
|
19
|
+
const MODES = new Set(['auto', 'learn', 'solve', 'practice', 'interview', 'project']);
|
|
20
|
+
const DEPTHS = new Set(['concise', 'standard', 'deep']);
|
|
21
|
+
const ESC = '\u001b[';
|
|
22
|
+
|
|
23
|
+
const ANSI = {
|
|
24
|
+
reset: `${ESC}0m`,
|
|
25
|
+
bold: `${ESC}1m`,
|
|
26
|
+
dim: `${ESC}2m`,
|
|
27
|
+
cyan: `${ESC}36m`,
|
|
28
|
+
yellow: `${ESC}33m`,
|
|
29
|
+
green: `${ESC}32m`,
|
|
30
|
+
blue: `${ESC}34m`,
|
|
31
|
+
magenta: `${ESC}35m`,
|
|
32
|
+
red: `${ESC}31m`,
|
|
33
|
+
gray: `${ESC}90m`,
|
|
34
|
+
white: `${ESC}97m`
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function color(code, value) {
|
|
38
|
+
if (!output.isTTY || process.env.NO_COLOR) return String(value);
|
|
39
|
+
return `${code}${value}${ANSI.reset}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function stripAnsi(value = '') {
|
|
43
|
+
return String(value).replace(/\u001b\[[0-9;]*m/g, '');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function clamp(value, min, max) {
|
|
47
|
+
return Math.max(min, Math.min(max, value));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function pad(value, width) {
|
|
51
|
+
const raw = String(value);
|
|
52
|
+
const len = stripAnsi(raw).length;
|
|
53
|
+
if (len >= width) return raw;
|
|
54
|
+
return raw + ' '.repeat(width - len);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function wrapText(value = '', width = 80) {
|
|
58
|
+
const target = Math.max(18, Number(width) || 80);
|
|
59
|
+
const out = [];
|
|
60
|
+
for (const paragraph of String(value).replace(/\r\n/g, '\n').split('\n')) {
|
|
61
|
+
if (!paragraph) {
|
|
62
|
+
out.push('');
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (paragraph.startsWith('```') || /^\s{4}/.test(paragraph)) {
|
|
66
|
+
out.push(paragraph);
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const indent = (paragraph.match(/^\s*(?:[-*•]|\d+\.)\s+/) || [''])[0];
|
|
70
|
+
const words = paragraph.trim().split(/\s+/);
|
|
71
|
+
let line = indent;
|
|
72
|
+
for (const word of words) {
|
|
73
|
+
const candidate = line.trim().length ? `${line}${line.endsWith(' ') ? '' : ' '}${word}` : word;
|
|
74
|
+
if (stripAnsi(candidate).length > target && line.trim()) {
|
|
75
|
+
out.push(line.trimEnd());
|
|
76
|
+
line = indent ? `${' '.repeat(indent.length)}${word}` : word;
|
|
77
|
+
} else {
|
|
78
|
+
line = candidate;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (line.trim()) out.push(line.trimEnd());
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function inferMode(query = '', preferred = 'auto') {
|
|
87
|
+
const forced = String(preferred || 'auto').toLowerCase();
|
|
88
|
+
if (MODES.has(forced) && forced !== 'auto') return forced;
|
|
89
|
+
const q = String(query).toLowerCase();
|
|
90
|
+
if (/\b(mock interview|interview me|ask me|interviewer|screen me)\b/.test(q)) return 'interview';
|
|
91
|
+
if (/\b(practice|quiz|drill|question set|questions for me|test me)\b/.test(q)) return 'practice';
|
|
92
|
+
if (/\b(project|portfolio project|research project|build a project|implementation roadmap)\b/.test(q)) return 'project';
|
|
93
|
+
if (/\b(solve|derive|calculate|compute|prove|show that|price|calibrate|estimate|find the|work out)\b/.test(q)) return 'solve';
|
|
94
|
+
return 'learn';
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function qualityContract(depth = 'deep') {
|
|
98
|
+
if (depth === 'concise') {
|
|
99
|
+
return 'Be direct and technically correct. State the key equation/result, define essential notation, and give the practical quant interpretation. Avoid generic filler.';
|
|
100
|
+
}
|
|
101
|
+
if (depth === 'standard') {
|
|
102
|
+
return 'Give a rigorous quant-level answer: intuition, notation, core derivation or logic, assumptions, one sanity check or example, and practical interpretation. Avoid hand-waving and generic filler.';
|
|
103
|
+
}
|
|
104
|
+
return [
|
|
105
|
+
'Answer at practitioner quantitative-finance level, not as a generic chatbot.',
|
|
106
|
+
'Start with the direct answer, then build intuition and mathematics.',
|
|
107
|
+
'Define notation before using it. Derive non-trivial claims step by step.',
|
|
108
|
+
'State modelling conventions and assumptions explicitly.',
|
|
109
|
+
'Check at least one limiting case, unit/dimension, sign, boundary condition, or calibration identity when relevant.',
|
|
110
|
+
'Use a small numerical example when it materially improves understanding.',
|
|
111
|
+
'Include desk/model-risk interpretation and common failure modes where relevant.',
|
|
112
|
+
'For code, give production-minded Python with numerical/validation considerations rather than toy pseudocode.',
|
|
113
|
+
'Do not invent market data, citations, or facts. If something is uncertain, say exactly what is uncertain.',
|
|
114
|
+
'End with a compact takeaway rather than repeating the whole answer.'
|
|
115
|
+
].join(' ');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function buildContextQuery(query, history = [], { depth = 'deep' } = {}) {
|
|
119
|
+
const current = String(query || '').trim();
|
|
120
|
+
const recent = Array.isArray(history) ? history.slice(-8) : [];
|
|
121
|
+
let context = recent
|
|
122
|
+
.map((m) => `${m.role === 'assistant' ? 'Desk2Quant' : 'User'}: ${String(m.content || '').trim()}`)
|
|
123
|
+
.join('\n\n');
|
|
124
|
+
if (context.length > 3200) context = context.slice(context.length - 3200);
|
|
125
|
+
const pieces = [
|
|
126
|
+
`QUALITY CONTRACT:\n${qualityContract(depth)}`,
|
|
127
|
+
context ? `RECENT CONVERSATION CONTEXT:\n${context}` : '',
|
|
128
|
+
`CURRENT USER REQUEST:\n${current}`,
|
|
129
|
+
'Answer the CURRENT USER REQUEST. Use conversation context only when it is relevant; do not restate it unnecessarily.'
|
|
130
|
+
].filter(Boolean);
|
|
131
|
+
const combined = pieces.join('\n\n');
|
|
132
|
+
return combined.length <= 5600 ? combined : `${combined.slice(0, 5600)}\n`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function configDir(env = process.env) {
|
|
136
|
+
return env.D2Q_CONFIG_DIR || path.join(os.homedir(), '.desk2quant');
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function historyPath(env = process.env) {
|
|
140
|
+
return path.join(configDir(env), 'tui-history.json');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function loadHistory(env = process.env) {
|
|
144
|
+
try {
|
|
145
|
+
const raw = await fs.readFile(historyPath(env), 'utf8');
|
|
146
|
+
const parsed = JSON.parse(raw);
|
|
147
|
+
return Array.isArray(parsed?.messages) ? parsed : { messages: [] };
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (err?.code === 'ENOENT') return { messages: [] };
|
|
150
|
+
return { messages: [] };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function saveHistory(messages, env = process.env) {
|
|
155
|
+
const dir = configDir(env);
|
|
156
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
157
|
+
const file = historyPath(env);
|
|
158
|
+
const payload = {
|
|
159
|
+
version: 1,
|
|
160
|
+
updatedAt: new Date().toISOString(),
|
|
161
|
+
messages: messages.slice(-40)
|
|
162
|
+
};
|
|
163
|
+
await fs.writeFile(file, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
164
|
+
try { await fs.chmod(file, 0o600); } catch (_) {}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function truncate(value, n) {
|
|
168
|
+
const text = String(value || '');
|
|
169
|
+
return text.length > n ? text.slice(0, Math.max(0, n - 1)) + '…' : text;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function divider(width, left = '├', right = '┤') {
|
|
173
|
+
return `${left}${'─'.repeat(Math.max(0, width - 2))}${right}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function top(width) { return `┌${'─'.repeat(Math.max(0, width - 2))}┐`; }
|
|
177
|
+
function bottom(width) { return `└${'─'.repeat(Math.max(0, width - 2))}┘`; }
|
|
178
|
+
|
|
179
|
+
function renderHeader(state, width) {
|
|
180
|
+
const inside = width - 4;
|
|
181
|
+
const left = color(ANSI.bold + ANSI.cyan, `Desk2Quant Quant Agent v${VERSION}`);
|
|
182
|
+
const right = color(ANSI.green, `${String(state.tier || 'pro').toUpperCase()} · ${state.remaining ?? '?'} left`);
|
|
183
|
+
const gap = Math.max(1, inside - stripAnsi(left).length - stripAnsi(right).length);
|
|
184
|
+
return [
|
|
185
|
+
top(width),
|
|
186
|
+
`│ ${left}${' '.repeat(gap)}${right} │`,
|
|
187
|
+
divider(width)
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function sidebarLines(state) {
|
|
192
|
+
const modeText = state.mode === 'auto' ? `auto → ${state.lastMode || 'learn'}` : state.mode;
|
|
193
|
+
return [
|
|
194
|
+
color(ANSI.yellow, 'MODE'),
|
|
195
|
+
` ${modeText}`,
|
|
196
|
+
'',
|
|
197
|
+
color(ANSI.yellow, 'DEPTH'),
|
|
198
|
+
` ${state.depth}`,
|
|
199
|
+
'',
|
|
200
|
+
color(ANSI.yellow, 'SESSION'),
|
|
201
|
+
` ${state.email}`,
|
|
202
|
+
` turns: ${Math.floor(state.history.length / 2)}`,
|
|
203
|
+
'',
|
|
204
|
+
color(ANSI.yellow, 'COMMANDS'),
|
|
205
|
+
' /mode /depth',
|
|
206
|
+
' /progress',
|
|
207
|
+
' /skills /assess',
|
|
208
|
+
' /context /history',
|
|
209
|
+
' /new /export',
|
|
210
|
+
' /help /quit'
|
|
211
|
+
];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function styleAnswerLine(line) {
|
|
215
|
+
const raw = String(line);
|
|
216
|
+
if (/^#{1,4}\s+/.test(raw)) return color(ANSI.bold + ANSI.cyan, raw.replace(/^#{1,4}\s+/, ''));
|
|
217
|
+
if (/^[A-Z][A-Z0-9 /&()_-]{4,}:?$/.test(raw.trim())) return color(ANSI.bold + ANSI.cyan, raw);
|
|
218
|
+
if (/^\s*(?:[-*•]|\d+\.)\s+/.test(raw)) return color(ANSI.white, raw);
|
|
219
|
+
if (/^```/.test(raw)) return color(ANSI.magenta, raw);
|
|
220
|
+
return raw;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function chatLines(state, width, heightBudget) {
|
|
224
|
+
const out = [];
|
|
225
|
+
const available = Math.max(20, width);
|
|
226
|
+
const messages = state.history.slice(-8);
|
|
227
|
+
for (const message of messages) {
|
|
228
|
+
const isUser = message.role === 'user';
|
|
229
|
+
const label = isUser ? color(ANSI.bold + ANSI.blue, 'YOU') : color(ANSI.bold + ANSI.green, 'DESK2QUANT');
|
|
230
|
+
out.push(label);
|
|
231
|
+
const body = isUser ? String(message.content || '') : formatTerminalMath(String(message.content || ''));
|
|
232
|
+
for (const line of wrapText(body, available)) out.push(isUser ? color(ANSI.white, line) : styleAnswerLine(line));
|
|
233
|
+
out.push('');
|
|
234
|
+
}
|
|
235
|
+
if (state.notice) {
|
|
236
|
+
out.push(color(ANSI.bold + ANSI.yellow, 'SYSTEM'));
|
|
237
|
+
for (const line of wrapText(state.notice, available)) out.push(color(ANSI.gray, line));
|
|
238
|
+
out.push('');
|
|
239
|
+
}
|
|
240
|
+
if (out.length > heightBudget) return out.slice(out.length - heightBudget);
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function renderScreen(state) {
|
|
245
|
+
const width = clamp(output.columns || 110, 72, 150);
|
|
246
|
+
const height = clamp(output.rows || 34, 24, 60);
|
|
247
|
+
const sidebarWidth = width >= 96 ? 24 : 0;
|
|
248
|
+
const contentWidth = sidebarWidth ? width - sidebarWidth - 5 : width - 4;
|
|
249
|
+
const head = renderHeader(state, width);
|
|
250
|
+
const budget = Math.max(8, height - head.length - 5);
|
|
251
|
+
const chat = chatLines(state, contentWidth, budget);
|
|
252
|
+
const side = sidebarWidth ? sidebarLines(state) : [];
|
|
253
|
+
const bodyRows = Math.max(chat.length, side.length, 8);
|
|
254
|
+
const lines = [...head];
|
|
255
|
+
for (let i = 0; i < bodyRows; i += 1) {
|
|
256
|
+
const sideText = sidebarWidth ? pad(side[i] || '', sidebarWidth) : '';
|
|
257
|
+
const chatText = pad(chat[i] || '', contentWidth);
|
|
258
|
+
if (sidebarWidth) lines.push(`│ ${sideText} │ ${chatText} │`);
|
|
259
|
+
else lines.push(`│ ${chatText} │`);
|
|
260
|
+
}
|
|
261
|
+
lines.push(divider(width));
|
|
262
|
+
const status = ` ${String(state.tier || 'pro').toUpperCase()} | mode ${state.mode}${state.mode === 'auto' ? `→${state.lastMode || 'learn'}` : ''} | depth ${state.depth} | ${state.remaining ?? '?'} requests left | /help `;
|
|
263
|
+
lines.push(`│${pad(color(ANSI.dim, truncate(status, width - 2)), width - 2)}│`);
|
|
264
|
+
lines.push(bottom(width));
|
|
265
|
+
|
|
266
|
+
if (output.isTTY) output.write(`${ESC}2J${ESC}H`);
|
|
267
|
+
output.write(lines.join('\n') + '\n');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function withSpinner(promise, label = 'Reasoning') {
|
|
271
|
+
if (!output.isTTY) return promise;
|
|
272
|
+
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
273
|
+
let i = 0;
|
|
274
|
+
const timer = setInterval(() => {
|
|
275
|
+
output.write(`\r${ESC}2K${color(ANSI.cyan, frames[i++ % frames.length])} ${label}…`);
|
|
276
|
+
}, 90);
|
|
277
|
+
try {
|
|
278
|
+
return await promise;
|
|
279
|
+
} finally {
|
|
280
|
+
clearInterval(timer);
|
|
281
|
+
output.write(`\r${ESC}2K`);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseSlash(line) {
|
|
286
|
+
const text = String(line || '').trim();
|
|
287
|
+
if (!text.startsWith('/')) return null;
|
|
288
|
+
const [name, ...rest] = text.slice(1).split(/\s+/);
|
|
289
|
+
return { name: String(name || '').toLowerCase(), args: rest, raw: rest.join(' ') };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async function exportTranscript(state) {
|
|
293
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
294
|
+
const file = path.resolve(process.cwd(), `desk2quant-session-${stamp}.md`);
|
|
295
|
+
const body = [
|
|
296
|
+
'# Desk2Quant Quant Agent Session',
|
|
297
|
+
'',
|
|
298
|
+
`- Account: ${state.email}`,
|
|
299
|
+
`- Mode: ${state.mode}`,
|
|
300
|
+
`- Depth: ${state.depth}`,
|
|
301
|
+
`- Exported: ${new Date().toISOString()}`,
|
|
302
|
+
'',
|
|
303
|
+
...state.history.flatMap((m) => [
|
|
304
|
+
`## ${m.role === 'user' ? 'You' : 'Desk2Quant'}`,
|
|
305
|
+
'',
|
|
306
|
+
String(m.content || ''),
|
|
307
|
+
''
|
|
308
|
+
])
|
|
309
|
+
].join('\n');
|
|
310
|
+
await fs.writeFile(file, body, 'utf8');
|
|
311
|
+
return file;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function helpText() {
|
|
315
|
+
return [
|
|
316
|
+
'Ask naturally; the agent automatically routes to learn/solve/practice/interview/project.',
|
|
317
|
+
'/mode auto|learn|solve|practice|interview|project — override routing',
|
|
318
|
+
'/depth concise|standard|deep — control answer depth',
|
|
319
|
+
'/progress — usage and activity',
|
|
320
|
+
'/skills — calibrated assessment skills',
|
|
321
|
+
'/assess <skill> — start an adaptive question',
|
|
322
|
+
'/submit <id> <answer> — grade an adaptive question',
|
|
323
|
+
'/context — show active local context',
|
|
324
|
+
'/history — show recent prompts',
|
|
325
|
+
'/sources — show source metadata from the latest response when available',
|
|
326
|
+
'/new — start a fresh conversation',
|
|
327
|
+
'/export — save the session as Markdown',
|
|
328
|
+
'/clear — clear transient system notices',
|
|
329
|
+
'/quit — exit the TUI'
|
|
330
|
+
].join('\n');
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function handleSlash(command, state) {
|
|
334
|
+
const { name, args, raw } = command;
|
|
335
|
+
if (name === 'help') { state.notice = helpText(); return false; }
|
|
336
|
+
if (name === 'quit' || name === 'exit') return true;
|
|
337
|
+
if (name === 'mode') {
|
|
338
|
+
const next = String(args[0] || '').toLowerCase();
|
|
339
|
+
if (!MODES.has(next)) state.notice = `Mode must be one of: ${[...MODES].join(', ')}`;
|
|
340
|
+
else { state.mode = next; state.notice = `Mode set to ${next}.`; }
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
if (name === 'depth') {
|
|
344
|
+
const next = String(args[0] || '').toLowerCase();
|
|
345
|
+
if (!DEPTHS.has(next)) state.notice = `Depth must be one of: ${[...DEPTHS].join(', ')}`;
|
|
346
|
+
else { state.depth = next; state.notice = `Answer depth set to ${next}.`; }
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
if (name === 'progress') {
|
|
350
|
+
const result = await withSpinner(getProgress(), 'Loading progress');
|
|
351
|
+
state.remaining = result?.progress?.remainingToday ?? state.remaining;
|
|
352
|
+
state.notice = formatProgress(result.progress);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
if (name === 'skills') {
|
|
356
|
+
const result = await withSpinner(getSkills(), 'Loading skills');
|
|
357
|
+
state.notice = `${formatSkills(result.skills)}${result.note ? `\n\n${result.note}` : ''}`;
|
|
358
|
+
return false;
|
|
359
|
+
}
|
|
360
|
+
if (name === 'assess') {
|
|
361
|
+
if (!raw) { state.notice = 'Usage: /assess <skill>'; return false; }
|
|
362
|
+
const result = await withSpinner(startAssessment(raw), 'Preparing assessment');
|
|
363
|
+
state.notice = `Assessment ID: ${result.assessmentId}\nSkill: ${result.skill} | difficulty b=${Number(result.difficulty).toFixed(2)} | current θ=${Number(result.currentTheta).toFixed(2)}\n\n${formatTerminalMath(result.question)}\n\nSubmit with: /submit ${result.assessmentId} "your answer"`;
|
|
364
|
+
return false;
|
|
365
|
+
}
|
|
366
|
+
if (name === 'submit') {
|
|
367
|
+
const id = args.shift();
|
|
368
|
+
const answer = args.join(' ').replace(/^"|"$/g, '');
|
|
369
|
+
if (!id || !answer) { state.notice = 'Usage: /submit <assessment-id> <answer>'; return false; }
|
|
370
|
+
const result = await withSpinner(submitAssessment(id, answer), 'Grading');
|
|
371
|
+
state.notice = `Score: ${(Number(result.score) * 100).toFixed(1)}%\nθ: ${Number(result.thetaBefore).toFixed(2)} → ${Number(result.thetaAfter).toFixed(2)}\nAttempts: ${result.attempts}\n\n${formatTerminalMath(result.feedback)}\n\n${formatTerminalMath(result.abilityNote)}`;
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
if (name === 'context') {
|
|
375
|
+
state.notice = `Account: ${state.email} (${state.tier})\nMode: ${state.mode}${state.mode === 'auto' ? ` → ${state.lastMode || 'learn'}` : ''}\nDepth: ${state.depth}\nConversation messages in local context: ${state.history.length}\nSession expires: ${new Date(Number(state.expiresAt)).toLocaleString()}`;
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
if (name === 'history') {
|
|
379
|
+
const prompts = state.history.filter((m) => m.role === 'user').slice(-8).map((m, i) => `${i + 1}. ${truncate(m.content, 110)}`);
|
|
380
|
+
state.notice = prompts.length ? prompts.join('\n') : 'No conversation history yet.';
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
if (name === 'sources') {
|
|
384
|
+
const sources = state.lastMeta?.sources;
|
|
385
|
+
state.notice = Array.isArray(sources) && sources.length
|
|
386
|
+
? sources.map((s, i) => `[${i + 1}] ${typeof s === 'string' ? s : JSON.stringify(s)}`).join('\n')
|
|
387
|
+
: 'The latest backend response did not return source-level metadata. The agent still uses its server-side quant knowledge anchor, but it will not fabricate citations.';
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
if (name === 'new') {
|
|
391
|
+
state.history = [];
|
|
392
|
+
state.notice = 'Started a fresh conversation. Previous local transcript was cleared.';
|
|
393
|
+
await saveHistory(state.history);
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
if (name === 'export') {
|
|
397
|
+
const file = await exportTranscript(state);
|
|
398
|
+
state.notice = `Session exported to:\n${file}`;
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
if (name === 'clear') { state.notice = ''; return false; }
|
|
402
|
+
state.notice = `Unknown command /${name}. Use /help.`;
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
export async function startTui({ env = process.env } = {}) {
|
|
407
|
+
const cfg = await loadConfig(env);
|
|
408
|
+
if (!cfg) throw new Error('Not signed in. Run `d2q login <purchase-email>` first, then run `d2q`.');
|
|
409
|
+
|
|
410
|
+
const stored = await loadHistory(env);
|
|
411
|
+
let progress = null;
|
|
412
|
+
try { progress = await getProgress({ env }); } catch (_) {}
|
|
413
|
+
|
|
414
|
+
const state = {
|
|
415
|
+
email: cfg.email,
|
|
416
|
+
tier: cfg.tier || 'pro',
|
|
417
|
+
expiresAt: cfg.expiresAt,
|
|
418
|
+
mode: 'auto',
|
|
419
|
+
lastMode: 'learn',
|
|
420
|
+
depth: 'deep',
|
|
421
|
+
remaining: progress?.progress?.remainingToday ?? '?',
|
|
422
|
+
history: stored.messages || [],
|
|
423
|
+
notice: 'Ask a quant question naturally. Use /help for controls.',
|
|
424
|
+
lastMeta: null
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
const rl = readline.createInterface({ input, output, terminal: true, historySize: 200 });
|
|
428
|
+
try {
|
|
429
|
+
while (true) {
|
|
430
|
+
renderScreen(state);
|
|
431
|
+
let line;
|
|
432
|
+
try { line = (await rl.question(color(ANSI.bold + ANSI.cyan, '› '))).trim(); }
|
|
433
|
+
catch (err) { if (err?.code === 'ERR_USE_AFTER_CLOSE') break; throw err; }
|
|
434
|
+
if (!line) { state.notice = ''; continue; }
|
|
435
|
+
|
|
436
|
+
const slash = parseSlash(line);
|
|
437
|
+
if (slash) {
|
|
438
|
+
try {
|
|
439
|
+
if (await handleSlash(slash, state)) break;
|
|
440
|
+
} catch (err) {
|
|
441
|
+
state.notice = `Error: ${err.message}`;
|
|
442
|
+
}
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
state.notice = '';
|
|
447
|
+
const mode = inferMode(line, state.mode);
|
|
448
|
+
state.lastMode = mode;
|
|
449
|
+
const query = buildContextQuery(line, state.history, { depth: state.depth });
|
|
450
|
+
try {
|
|
451
|
+
const result = await withSpinner(runCommand(mode, query, { env }), `Reasoning · ${mode}`);
|
|
452
|
+
state.history.push({ role: 'user', content: line, at: Date.now() });
|
|
453
|
+
state.history.push({ role: 'assistant', content: result.content, at: Date.now(), mode });
|
|
454
|
+
state.history = state.history.slice(-40);
|
|
455
|
+
state.remaining = result?.meta?.remainingToday ?? state.remaining;
|
|
456
|
+
state.lastMeta = result?.meta || null;
|
|
457
|
+
await saveHistory(state.history, env);
|
|
458
|
+
} catch (err) {
|
|
459
|
+
state.notice = `Request failed: ${err.message}`;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
} finally {
|
|
463
|
+
rl.close();
|
|
464
|
+
if (output.isTTY) output.write(`${ESC}0m\n`);
|
|
465
|
+
}
|
|
466
|
+
}
|