@taj-special/dravix-code 1.1.1
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/dist/cli/commands.js +100 -0
- package/dist/cli/index.js +280 -0
- package/dist/cli/repl.js +2833 -0
- package/dist/services/ai.js +206 -0
- package/dist/services/auth.js +67 -0
- package/dist/services/context.js +128 -0
- package/dist/services/conversations.js +62 -0
- package/dist/services/executor.js +628 -0
- package/dist/services/usage.js +60 -0
- package/dist/utils/display.js +258 -0
- package/dist/utils/fileops.js +17 -0
- package/package.json +35 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const AI_PROXY = 'https://dravix.app/ai-proxy.php';
|
|
2
|
+
const PROMPT_URL = 'https://dravix.app/cli-prompt.php';
|
|
3
|
+
export async function fetchSystemPrompt(token) {
|
|
4
|
+
try {
|
|
5
|
+
const res = await fetch(`${PROMPT_URL}?token=${encodeURIComponent(token)}`, {
|
|
6
|
+
signal: AbortSignal.timeout(10000),
|
|
7
|
+
});
|
|
8
|
+
if (!res.ok)
|
|
9
|
+
return { prompt: '', webDesignerSkill: '', isCreator: false };
|
|
10
|
+
const data = await res.json();
|
|
11
|
+
return {
|
|
12
|
+
prompt: data.prompt ?? '',
|
|
13
|
+
webDesignerSkill: data.web_designer_skill ?? '',
|
|
14
|
+
isCreator: data.is_creator ?? false,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return { prompt: '', webDesignerSkill: '', isCreator: false };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const TIMEOUT_MS = 300_000; // 5 min — AI may generate large responses
|
|
22
|
+
const CHUNK_TIMEOUT_MS = 60_000; // 1 min without any chunk = abort
|
|
23
|
+
const MAX_CONTINUATIONS = 5; // auto-continue up to 5 times on length cutoff
|
|
24
|
+
// Convert history to OpenAI-compatible format:
|
|
25
|
+
// Only the first message can be role:system — everything else must alternate user/assistant.
|
|
26
|
+
// Mid-conversation system messages (file contents, /add files) become role:user.
|
|
27
|
+
// Consecutive user/system-as-user messages are merged to maintain proper alternation.
|
|
28
|
+
function prepareMessages(messages) {
|
|
29
|
+
// Trim context: if total chars exceed ~180k (~45k tokens), drop oldest non-system messages
|
|
30
|
+
const MAX_CHARS = 80_000;
|
|
31
|
+
let totalChars = messages.reduce((s, m) => s + String(m.content).length, 0);
|
|
32
|
+
const trimmed = [...messages];
|
|
33
|
+
while (totalChars > MAX_CHARS && trimmed.length > 4) {
|
|
34
|
+
const removed = trimmed.splice(1, 1)[0];
|
|
35
|
+
totalChars -= String(removed.content).length;
|
|
36
|
+
}
|
|
37
|
+
const converted = trimmed.map((msg, i) => {
|
|
38
|
+
if (msg.role === 'system' && i > 0)
|
|
39
|
+
return { role: 'user', content: msg.content };
|
|
40
|
+
return msg;
|
|
41
|
+
});
|
|
42
|
+
const merged = [];
|
|
43
|
+
for (const msg of converted) {
|
|
44
|
+
const last = merged[merged.length - 1];
|
|
45
|
+
if (last && last.role === 'user' && msg.role === 'user') {
|
|
46
|
+
last.content = last.content + '\n\n' + msg.content;
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
merged.push({ ...msg });
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return merged;
|
|
53
|
+
}
|
|
54
|
+
async function doSingleRequest(messages, token, abort, onChunk) {
|
|
55
|
+
const requestTimer = setTimeout(() => abort.abort(), TIMEOUT_MS);
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(AI_PROXY, {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
signal: abort.signal,
|
|
60
|
+
headers: {
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
'X-CLI-Token': token,
|
|
63
|
+
'X-CLI-Version': '1.1.0',
|
|
64
|
+
},
|
|
65
|
+
body: JSON.stringify({
|
|
66
|
+
provider: 'openrouter',
|
|
67
|
+
model: 'deepseek/deepseek-v4-flash',
|
|
68
|
+
messages: prepareMessages(messages),
|
|
69
|
+
stream: true,
|
|
70
|
+
temperature: 0.1,
|
|
71
|
+
max_tokens: 16384,
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
if (!res.ok || !res.body) {
|
|
75
|
+
const text = await res.text().catch(() => '');
|
|
76
|
+
let msg = `HTTP ${res.status}`;
|
|
77
|
+
try {
|
|
78
|
+
msg = JSON.parse(text).error ?? msg;
|
|
79
|
+
}
|
|
80
|
+
catch { }
|
|
81
|
+
throw new Error(msg);
|
|
82
|
+
}
|
|
83
|
+
const reader = res.body.getReader();
|
|
84
|
+
const decoder = new TextDecoder();
|
|
85
|
+
let buffer = '';
|
|
86
|
+
let finishReason = 'stop';
|
|
87
|
+
let chunkResponse = '';
|
|
88
|
+
outer: while (true) {
|
|
89
|
+
let chunkTimer = setTimeout(() => abort.abort(), CHUNK_TIMEOUT_MS);
|
|
90
|
+
const { done, value } = await reader.read().finally(() => {
|
|
91
|
+
if (chunkTimer) {
|
|
92
|
+
clearTimeout(chunkTimer);
|
|
93
|
+
chunkTimer = null;
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
if (done)
|
|
97
|
+
break;
|
|
98
|
+
buffer += decoder.decode(value, { stream: true });
|
|
99
|
+
const lines = buffer.split('\n');
|
|
100
|
+
buffer = lines.pop() ?? '';
|
|
101
|
+
for (const line of lines) {
|
|
102
|
+
if (!line.startsWith('data: '))
|
|
103
|
+
continue;
|
|
104
|
+
const data = line.slice(6).trim();
|
|
105
|
+
if (data === '[DONE]')
|
|
106
|
+
break outer;
|
|
107
|
+
let json;
|
|
108
|
+
try {
|
|
109
|
+
json = JSON.parse(data);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
// Detect API/proxy error events in the SSE stream — was previously silently ignored
|
|
115
|
+
if (json.error) {
|
|
116
|
+
const e = json.error;
|
|
117
|
+
const msg = typeof e.message === 'string' ? e.message
|
|
118
|
+
: typeof json.error === 'string' ? json.error
|
|
119
|
+
: JSON.stringify(json.error);
|
|
120
|
+
throw new Error(msg);
|
|
121
|
+
}
|
|
122
|
+
const choices = json.choices;
|
|
123
|
+
const delta = choices?.[0]?.delta;
|
|
124
|
+
const text = (delta?.content ?? '');
|
|
125
|
+
const rawFr = (choices?.[0]?.finish_reason ?? '');
|
|
126
|
+
if (rawFr && rawFr !== 'null')
|
|
127
|
+
finishReason = rawFr;
|
|
128
|
+
if (text) {
|
|
129
|
+
chunkResponse += text;
|
|
130
|
+
onChunk(text);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
// Detect non-SSE JSON error body (e.g. proxy returns plain JSON error instead of SSE)
|
|
135
|
+
if (!chunkResponse) {
|
|
136
|
+
if (buffer.trim()) {
|
|
137
|
+
try {
|
|
138
|
+
const errData = JSON.parse(buffer.trim());
|
|
139
|
+
if (errData.error) {
|
|
140
|
+
const e = errData.error;
|
|
141
|
+
const msg = typeof e.message === 'string' ? e.message
|
|
142
|
+
: typeof errData.error === 'string' ? errData.error
|
|
143
|
+
: JSON.stringify(errData.error);
|
|
144
|
+
throw new Error(msg);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (pe) {
|
|
148
|
+
if (!(pe instanceof SyntaxError))
|
|
149
|
+
throw pe;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (!finishReason || finishReason === 'stop') {
|
|
153
|
+
throw new Error('No response from AI — check your connection or try again');
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { finishReason, response: chunkResponse };
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
clearTimeout(requestTimer);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
export async function streamChat(messages, token, onChunk, onDone, onError, cancelSignal) {
|
|
163
|
+
const abort = new AbortController();
|
|
164
|
+
const forwardCancel = () => abort.abort();
|
|
165
|
+
cancelSignal?.addEventListener('abort', forwardCancel);
|
|
166
|
+
try {
|
|
167
|
+
let currentMessages = [...messages];
|
|
168
|
+
for (let attempt = 0; attempt <= MAX_CONTINUATIONS; attempt++) {
|
|
169
|
+
if (cancelSignal?.aborted) {
|
|
170
|
+
onDone();
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const { finishReason, response } = await doSingleRequest(currentMessages, token, abort, onChunk);
|
|
174
|
+
if (finishReason === 'length' && attempt < MAX_CONTINUATIONS) {
|
|
175
|
+
// Token limit hit — auto-continue seamlessly, no visible break to user
|
|
176
|
+
const tail = response.slice(-400);
|
|
177
|
+
// Detect if cut off inside an open tag — need to complete it first
|
|
178
|
+
const openTagMatch = response.match(/<(write_file|edit_file|run_command|read_file|search_code)[^>]*>(?:(?!<\/\1>)[\s\S])*$/);
|
|
179
|
+
const contPrompt = openTagMatch
|
|
180
|
+
? `Your previous response was cut off inside a <${openTagMatch[1]}> tag. The tail was:\n"...${tail}"\nFirst COMPLETE the current tag (close it properly), then continue with any remaining work. Output ONLY the raw continuation — no intro, no preamble.`
|
|
181
|
+
: `Your previous response was cut off. It ended with:\n"...${tail}"\nContinue from that exact point — complete any remaining file edits or tasks. Output ONLY the raw continuation — no intro, no preamble, no repeated text.`;
|
|
182
|
+
currentMessages = [
|
|
183
|
+
...currentMessages,
|
|
184
|
+
{ role: 'assistant', content: response },
|
|
185
|
+
{ role: 'user', content: contPrompt },
|
|
186
|
+
];
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
onDone();
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
195
|
+
if (e.name === 'AbortError' && cancelSignal?.aborted) {
|
|
196
|
+
onDone(); // user-initiated cancel → clean stop with partial response
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
const msg = e.name === 'AbortError' ? 'Request timed out — check your connection.' : e.message;
|
|
200
|
+
onError(new Error(msg));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
cancelSignal?.removeEventListener('abort', forwardCancel);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
const CONFIG_DIR = path.join(os.homedir(), '.dravix-code');
|
|
5
|
+
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
6
|
+
const AUTH_URL = 'https://dravix.app/cli-auth';
|
|
7
|
+
const VERIFY_URL = 'https://dravix.app/cli-auth?action=verify&token=';
|
|
8
|
+
function readConfig() {
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function writeConfig(config) {
|
|
17
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
18
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8');
|
|
19
|
+
}
|
|
20
|
+
export function saveConfig(config) {
|
|
21
|
+
writeConfig(config);
|
|
22
|
+
}
|
|
23
|
+
export function getToken() {
|
|
24
|
+
return readConfig().token ?? null;
|
|
25
|
+
}
|
|
26
|
+
export function getSavedUser() {
|
|
27
|
+
const c = readConfig();
|
|
28
|
+
return { name: c.name, email: c.email };
|
|
29
|
+
}
|
|
30
|
+
export function isLoggedIn() {
|
|
31
|
+
return !!getToken();
|
|
32
|
+
}
|
|
33
|
+
export function logout() {
|
|
34
|
+
const config = readConfig();
|
|
35
|
+
delete config.token;
|
|
36
|
+
delete config.name;
|
|
37
|
+
delete config.email;
|
|
38
|
+
writeConfig(config);
|
|
39
|
+
}
|
|
40
|
+
export async function verifyAndSave(token) {
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(VERIFY_URL + encodeURIComponent(token));
|
|
43
|
+
const data = await res.json();
|
|
44
|
+
if (!data.valid)
|
|
45
|
+
return null;
|
|
46
|
+
writeConfig({ token, name: data.name ?? '', email: data.email ?? '' });
|
|
47
|
+
return { name: data.name ?? '', email: data.email ?? '' };
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export async function checkPlan(email) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch('https://dravix.app/check_user_plus.php', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers: { 'Content-Type': 'application/json' },
|
|
58
|
+
body: JSON.stringify({ email }),
|
|
59
|
+
});
|
|
60
|
+
const data = await res.json();
|
|
61
|
+
return data.isPlus ? 'Plus' : 'Free';
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return 'Free';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
export { AUTH_URL };
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
const IGNORE = new Set([
|
|
5
|
+
'node_modules', '.git', 'dist', 'build', '.next', 'out',
|
|
6
|
+
'__pycache__', '.cache', 'coverage', '.turbo',
|
|
7
|
+
]);
|
|
8
|
+
const CODE_EXTS = new Set([
|
|
9
|
+
'.ts', '.tsx', '.js', '.jsx', '.py', '.html', '.css', '.scss',
|
|
10
|
+
'.json', '.md', '.txt', '.sh', '.php', '.sql', '.yaml', '.yml',
|
|
11
|
+
'.java', '.go', '.rs', '.c', '.cpp', '.h', '.vue', '.svelte',
|
|
12
|
+
]);
|
|
13
|
+
export function getProjectFiles(root, maxFiles = 30) {
|
|
14
|
+
const files = [];
|
|
15
|
+
function walk(dir, depth = 0) {
|
|
16
|
+
if (depth > 4 || files.length >= maxFiles)
|
|
17
|
+
return;
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
if (IGNORE.has(e.name) || e.name.startsWith('.'))
|
|
27
|
+
continue;
|
|
28
|
+
const fullPath = path.join(dir, e.name);
|
|
29
|
+
if (e.isDirectory()) {
|
|
30
|
+
walk(fullPath, depth + 1);
|
|
31
|
+
}
|
|
32
|
+
else if (CODE_EXTS.has(path.extname(e.name).toLowerCase())) {
|
|
33
|
+
files.push(path.relative(root, fullPath));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
walk(root);
|
|
38
|
+
return files;
|
|
39
|
+
}
|
|
40
|
+
export function readFileContent(filePath, maxLen = 8000) {
|
|
41
|
+
try {
|
|
42
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
43
|
+
if (content.length > maxLen)
|
|
44
|
+
return content.slice(0, maxLen) + '\n... (truncated)';
|
|
45
|
+
return content;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return '';
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function getGitStatus(root) {
|
|
52
|
+
try {
|
|
53
|
+
return execSync('git status --short', {
|
|
54
|
+
cwd: root,
|
|
55
|
+
encoding: 'utf-8',
|
|
56
|
+
timeout: 5000,
|
|
57
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
58
|
+
}).trim();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return '';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const STOP_WORDS = new Set([
|
|
65
|
+
'this', 'that', 'with', 'from', 'have', 'will', 'what', 'when', 'where', 'make', 'into',
|
|
66
|
+
'does', 'should', 'would', 'could', 'then', 'they', 'your', 'just', 'also', 'more',
|
|
67
|
+
// Tajik/Farsi common words
|
|
68
|
+
'дустам', 'хамин', 'барои', 'метавонед', 'кунед', 'мекунад', 'мешавад', 'аст',
|
|
69
|
+
'ман', 'хам', 'хамон', 'дар', 'ва', 'ки', 'ба', 'аз', 'он', 'буд', 'мебошад', 'кун',
|
|
70
|
+
]);
|
|
71
|
+
/**
|
|
72
|
+
* Extract only the relevant sections of a large file based on user message keywords.
|
|
73
|
+
* Returns numbered lines with context around each match.
|
|
74
|
+
*/
|
|
75
|
+
export function extractRelevantSections(fileContent, userMessage, contextLines = 35) {
|
|
76
|
+
const lines = fileContent.split('\n');
|
|
77
|
+
const total = lines.length;
|
|
78
|
+
// Extract latin technical keywords (CSS classes, HTML tags, JS identifiers)
|
|
79
|
+
const keywords = [...new Set(userMessage
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.split(/[\s.,!?;:()\[\]{}'"`\/\\]+/)
|
|
82
|
+
.filter(w => w.length >= 3 && !STOP_WORDS.has(w) && /^[\w\-]+$/.test(w)))];
|
|
83
|
+
if (keywords.length === 0) {
|
|
84
|
+
// No latin keywords — return structural preview
|
|
85
|
+
const preview = lines.slice(0, 80).map((l, i) => `${String(i + 1).padStart(4)} │ ${l}`).join('\n');
|
|
86
|
+
return `[File: ${total} lines — no specific keywords found. First 80 lines shown.]\n${preview}\n[Use <read_file lines="N-M"/> to read any other section]`;
|
|
87
|
+
}
|
|
88
|
+
// Find lines matching any keyword
|
|
89
|
+
const included = new Set();
|
|
90
|
+
for (let i = 0; i < lines.length; i++) {
|
|
91
|
+
const low = lines[i].toLowerCase();
|
|
92
|
+
if (keywords.some(kw => low.includes(kw))) {
|
|
93
|
+
const from = Math.max(0, i - contextLines);
|
|
94
|
+
const to = Math.min(total - 1, i + contextLines);
|
|
95
|
+
for (let c = from; c <= to; c++)
|
|
96
|
+
included.add(c);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (included.size === 0) {
|
|
100
|
+
const preview = lines.slice(0, 80).map((l, i) => `${String(i + 1).padStart(4)} │ ${l}`).join('\n');
|
|
101
|
+
return `[File: ${total} lines — keywords [${keywords.slice(0, 5).join(', ')}] not found. First 80 lines shown.]\n${preview}\n[Use <search_code> to find specific text]`;
|
|
102
|
+
}
|
|
103
|
+
const sorted = [...included].sort((a, b) => a - b);
|
|
104
|
+
const out = [
|
|
105
|
+
`[File: ${total} lines — showing ${included.size} relevant lines for: ${keywords.slice(0, 6).join(', ')}]`,
|
|
106
|
+
`[⚠ In <find>: copy ONLY the text AFTER " │ " — never include line numbers]`,
|
|
107
|
+
];
|
|
108
|
+
let prev = -2;
|
|
109
|
+
for (const idx of sorted) {
|
|
110
|
+
if (idx > prev + 1)
|
|
111
|
+
out.push(' ···');
|
|
112
|
+
out.push(`${String(idx + 1).padStart(4)} │ ${lines[idx]}`);
|
|
113
|
+
prev = idx;
|
|
114
|
+
}
|
|
115
|
+
out.push(`[Use <read_file lines="N-M"/> to read any section not shown above]`);
|
|
116
|
+
return out.join('\n');
|
|
117
|
+
}
|
|
118
|
+
export function buildContext(root) {
|
|
119
|
+
const files = getProjectFiles(root);
|
|
120
|
+
const git = getGitStatus(root);
|
|
121
|
+
const projectName = path.basename(root);
|
|
122
|
+
return `Project: ${projectName}
|
|
123
|
+
Working dir: ${root}
|
|
124
|
+
Git status:
|
|
125
|
+
${git || '(clean)'}
|
|
126
|
+
Files:
|
|
127
|
+
${files.length > 0 ? files.join('\n') : '(empty project)'}`;
|
|
128
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import * as fs from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
const CONV_DIR = path.join(os.homedir(), '.dravix-code', 'conversations');
|
|
5
|
+
function ensureDir() {
|
|
6
|
+
fs.mkdirSync(CONV_DIR, { recursive: true });
|
|
7
|
+
}
|
|
8
|
+
export function generateId() {
|
|
9
|
+
return Date.now().toString(36) + Math.random().toString(36).slice(2, 9) + Math.random().toString(36).slice(2, 5);
|
|
10
|
+
}
|
|
11
|
+
export function generateTitle(messages) {
|
|
12
|
+
const first = messages.find(m => m.role === 'user');
|
|
13
|
+
if (!first)
|
|
14
|
+
return 'New conversation';
|
|
15
|
+
return String(first.content).replace(/\s+/g, ' ').trim().slice(0, 60) || 'New conversation';
|
|
16
|
+
}
|
|
17
|
+
export function saveConversation(id, title, cwd, messages) {
|
|
18
|
+
const chat = messages.filter(m => m.role === 'user' || m.role === 'assistant');
|
|
19
|
+
if (chat.length === 0)
|
|
20
|
+
return;
|
|
21
|
+
ensureDir();
|
|
22
|
+
const data = {
|
|
23
|
+
id, title, cwd,
|
|
24
|
+
updatedAt: new Date().toISOString(),
|
|
25
|
+
messageCount: chat.length,
|
|
26
|
+
messages,
|
|
27
|
+
};
|
|
28
|
+
try {
|
|
29
|
+
fs.writeFileSync(path.join(CONV_DIR, `${id}.json`), JSON.stringify(data), 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
catch { /* ignore write errors */ }
|
|
32
|
+
}
|
|
33
|
+
export function loadConversation(id) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(fs.readFileSync(path.join(CONV_DIR, `${id}.json`), 'utf-8'));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function listConversations(limit = 30) {
|
|
42
|
+
ensureDir();
|
|
43
|
+
try {
|
|
44
|
+
return fs.readdirSync(CONV_DIR)
|
|
45
|
+
.filter(f => f.endsWith('.json'))
|
|
46
|
+
.map(f => {
|
|
47
|
+
try {
|
|
48
|
+
const c = JSON.parse(fs.readFileSync(path.join(CONV_DIR, f), 'utf-8'));
|
|
49
|
+
return { id: c.id, title: c.title, cwd: c.cwd, updatedAt: c.updatedAt, messageCount: c.messageCount };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.filter((c) => c !== null)
|
|
56
|
+
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime())
|
|
57
|
+
.slice(0, limit);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
}
|