dave-code 1.0.3 โ 1.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/README.md +16 -1
- package/bin/aiClient.js +658 -164
- package/bin/cliMenu.js +127 -185
- package/bin/commandRouter.js +50 -0
- package/bin/configManager.js +98 -19
- package/bin/contextManager.js +151 -0
- package/bin/index.js +827 -450
- package/bin/install.js +3 -5
- package/bin/planManager.js +239 -0
- package/bin/runtimeEvents.js +62 -0
- package/bin/sessionManager.js +166 -0
- package/bin/terminalRenderer.js +283 -0
- package/bin/toolRuntime.js +1052 -0
- package/package.json +3 -2
package/bin/install.js
CHANGED
|
@@ -2,8 +2,8 @@ import ora from 'ora';
|
|
|
2
2
|
|
|
3
3
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
4
|
|
|
5
|
-
async function
|
|
6
|
-
console.log('\n\x1b[1;38;2;250;100;30m=== Dave Code
|
|
5
|
+
export async function runWelcomeAnimation() {
|
|
6
|
+
console.log('\n\x1b[1;38;2;250;100;30m=== Welcome to Dave Code ===\x1b[0m\n');
|
|
7
7
|
|
|
8
8
|
// Stage 1: Environment Check
|
|
9
9
|
const spinner1 = ora({
|
|
@@ -40,11 +40,9 @@ async function runInstallationAnimation() {
|
|
|
40
40
|
/ /_/ / /_/ /| |/ / __// /___/ /_/ / /_/ / __/
|
|
41
41
|
/_____/\\__,_/ |___/\\___/ \\____/\\____/\\__,_/\\___/ ` + '\x1b[0m\n');
|
|
42
42
|
|
|
43
|
-
console.log('\x1b[1;32mโ
|
|
43
|
+
console.log('\x1b[1;32mโ Initialization completed successfully! ๐\x1b[0m');
|
|
44
44
|
console.log('\x1b[90m--------------------------------------------------\x1b[0m');
|
|
45
45
|
console.log('๐ \x1b[1;36mDave Code\x1b[0m is now ready for use!');
|
|
46
46
|
console.log('๐ก Type \x1b[33mcalldave\x1b[0m in your terminal to start pair programming.');
|
|
47
47
|
console.log('\x1b[90m--------------------------------------------------\x1b[0m\n');
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
runInstallationAnimation().catch(console.error);
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
|
|
6
|
+
const STORE_VERSION = 1;
|
|
7
|
+
const VALID_STATUSES = new Set(['ready', 'running', 'completed', 'blocked']);
|
|
8
|
+
let plansBaseDir = path.join(os.homedir(), '.dave-code-plans');
|
|
9
|
+
|
|
10
|
+
export function setPlansBaseDirForTesting(directory) {
|
|
11
|
+
plansBaseDir = directory;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function canonicalWorkspaceRoot(workspaceRoot) {
|
|
15
|
+
const resolved = path.resolve(workspaceRoot || process.cwd());
|
|
16
|
+
let canonical = resolved;
|
|
17
|
+
try {
|
|
18
|
+
canonical = fs.realpathSync.native(resolved);
|
|
19
|
+
} catch {
|
|
20
|
+
// A workspace is validated by the caller. Keep the resolved value for diagnostics.
|
|
21
|
+
}
|
|
22
|
+
return process.platform === 'win32' ? canonical.toLowerCase() : canonical;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function workspaceKey(workspaceRoot) {
|
|
26
|
+
return crypto.createHash('sha256').update(canonicalWorkspaceRoot(workspaceRoot)).digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getPlansFile(workspaceRoot) {
|
|
30
|
+
return path.join(plansBaseDir, `${workspaceKey(workspaceRoot)}.json`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function ensurePlansDir() {
|
|
34
|
+
fs.mkdirSync(plansBaseDir, { recursive: true, mode: 0o700 });
|
|
35
|
+
try {
|
|
36
|
+
fs.chmodSync(plansBaseDir, 0o700);
|
|
37
|
+
} catch {
|
|
38
|
+
// Windows does not implement POSIX modes in the same way.
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeName(name) {
|
|
43
|
+
return String(name || '').trim().toLocaleLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function validatePlanName(name) {
|
|
47
|
+
const value = String(name || '').trim();
|
|
48
|
+
if (!value) throw new Error('Plan name is required.');
|
|
49
|
+
if ([...value].length > 60) throw new Error('Plan name must be 60 characters or fewer.');
|
|
50
|
+
if (value.includes(':') || /[\u0000-\u001f\u007f]/.test(value)) {
|
|
51
|
+
throw new Error('Plan name cannot contain a colon or control characters.');
|
|
52
|
+
}
|
|
53
|
+
return value;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function validatePlanContent(content) {
|
|
57
|
+
const text = String(content || '');
|
|
58
|
+
const checks = [
|
|
59
|
+
{ label: 'goal and acceptance criteria', pattern: /็ฎๆ |้ชๆถ|goal|acceptance/i },
|
|
60
|
+
{ label: 'verified current-state facts', pattern: /ๅฝๅ|็ฐ็ถ|ไบๅฎ|current state|verified facts/i },
|
|
61
|
+
{ label: 'ordered implementation steps', pattern: /ๆญฅ้ชค|step\s*\d|implementation steps/i },
|
|
62
|
+
{ label: 'what and how details', pattern: /ๅไปไน|ๆไนๅ|ๅ
ทไฝๅฎ็ฐ|what|how|implementation/i },
|
|
63
|
+
{ label: 'validation', pattern: /้ช่ฏ|ๆต่ฏ|validation|test plan/i },
|
|
64
|
+
{ label: 'risks and rollback', pattern: /้ฃ้ฉ|ๅ้|ๅๆป|risk|rollback/i }
|
|
65
|
+
];
|
|
66
|
+
return checks.filter(check => !check.pattern.test(text)).map(check => check.label);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function validateStoredPlan(plan) {
|
|
70
|
+
if (!plan || typeof plan !== 'object') return null;
|
|
71
|
+
try {
|
|
72
|
+
const name = validatePlanName(plan.name);
|
|
73
|
+
const status = VALID_STATUSES.has(plan.status) ? plan.status : 'ready';
|
|
74
|
+
return {
|
|
75
|
+
id: String(plan.id || crypto.randomUUID()),
|
|
76
|
+
name,
|
|
77
|
+
status,
|
|
78
|
+
request: String(plan.request || ''),
|
|
79
|
+
content: String(plan.content || ''),
|
|
80
|
+
createdAt: Number(plan.createdAt) || Date.now(),
|
|
81
|
+
updatedAt: Number(plan.updatedAt) || Date.now(),
|
|
82
|
+
lastRun: plan.lastRun && typeof plan.lastRun === 'object' ? plan.lastRun : null
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function emptyStore(workspaceRoot) {
|
|
90
|
+
return {
|
|
91
|
+
version: STORE_VERSION,
|
|
92
|
+
workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
|
|
93
|
+
plans: []
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function loadPlanStore(workspaceRoot) {
|
|
98
|
+
const filePath = getPlansFile(workspaceRoot);
|
|
99
|
+
try {
|
|
100
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
101
|
+
if (!parsed || parsed.version !== STORE_VERSION || !Array.isArray(parsed.plans)) {
|
|
102
|
+
return emptyStore(workspaceRoot);
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
version: STORE_VERSION,
|
|
106
|
+
workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
|
|
107
|
+
plans: parsed.plans.map(validateStoredPlan).filter(Boolean)
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return emptyStore(workspaceRoot);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function atomicWriteJson(filePath, value) {
|
|
115
|
+
ensurePlansDir();
|
|
116
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
117
|
+
fs.writeFileSync(tempPath, JSON.stringify(value, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
118
|
+
try {
|
|
119
|
+
fs.chmodSync(tempPath, 0o600);
|
|
120
|
+
} catch {
|
|
121
|
+
// Best effort on platforms without POSIX permissions.
|
|
122
|
+
}
|
|
123
|
+
try {
|
|
124
|
+
fs.renameSync(tempPath, filePath);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (process.platform !== 'win32' || !fs.existsSync(filePath)) throw error;
|
|
127
|
+
fs.unlinkSync(filePath);
|
|
128
|
+
fs.renameSync(tempPath, filePath);
|
|
129
|
+
} finally {
|
|
130
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function savePlanStore(workspaceRoot, store) {
|
|
135
|
+
const normalized = {
|
|
136
|
+
version: STORE_VERSION,
|
|
137
|
+
workspaceRoot: canonicalWorkspaceRoot(workspaceRoot),
|
|
138
|
+
plans: (store.plans || []).map(validateStoredPlan).filter(Boolean)
|
|
139
|
+
};
|
|
140
|
+
atomicWriteJson(getPlansFile(workspaceRoot), normalized);
|
|
141
|
+
return normalized;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function listPlans(workspaceRoot) {
|
|
145
|
+
return loadPlanStore(workspaceRoot).plans.sort((a, b) => {
|
|
146
|
+
const rank = status => status === 'running' ? 0 : status === 'ready' ? 1 : status === 'blocked' ? 2 : 3;
|
|
147
|
+
return rank(a.status) - rank(b.status) || b.updatedAt - a.updatedAt;
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function findPlan(workspaceRoot, name) {
|
|
152
|
+
const key = normalizeName(name);
|
|
153
|
+
return listPlans(workspaceRoot).find(plan => normalizeName(plan.name) === key) || null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function upsertPlan(workspaceRoot, { name, request, content }, { replace = false } = {}) {
|
|
157
|
+
const validName = validatePlanName(name);
|
|
158
|
+
const validContent = String(content || '').trim();
|
|
159
|
+
const missingSections = validatePlanContent(validContent);
|
|
160
|
+
if (missingSections.length > 0) throw new Error(`Plan content is incomplete: missing ${missingSections.join(', ')}.`);
|
|
161
|
+
const store = loadPlanStore(workspaceRoot);
|
|
162
|
+
const index = store.plans.findIndex(plan => normalizeName(plan.name) === normalizeName(validName));
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
if (index !== -1 && !replace) throw new Error(`Plan already exists: ${store.plans[index].name}`);
|
|
165
|
+
|
|
166
|
+
const next = index === -1
|
|
167
|
+
? {
|
|
168
|
+
id: crypto.randomUUID(),
|
|
169
|
+
name: validName,
|
|
170
|
+
status: 'ready',
|
|
171
|
+
request: String(request || '').trim(),
|
|
172
|
+
content: validContent,
|
|
173
|
+
createdAt: now,
|
|
174
|
+
updatedAt: now,
|
|
175
|
+
lastRun: null
|
|
176
|
+
}
|
|
177
|
+
: {
|
|
178
|
+
...store.plans[index],
|
|
179
|
+
name: validName,
|
|
180
|
+
status: 'ready',
|
|
181
|
+
request: String(request || '').trim(),
|
|
182
|
+
content: validContent,
|
|
183
|
+
updatedAt: now,
|
|
184
|
+
lastRun: null
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
if (index === -1) store.plans.push(next);
|
|
188
|
+
else store.plans[index] = next;
|
|
189
|
+
savePlanStore(workspaceRoot, store);
|
|
190
|
+
return next;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function updatePlan(workspaceRoot, planId, updates = {}) {
|
|
194
|
+
const store = loadPlanStore(workspaceRoot);
|
|
195
|
+
const index = store.plans.findIndex(plan => plan.id === planId);
|
|
196
|
+
if (index === -1) return null;
|
|
197
|
+
const nextStatus = updates.status === undefined ? store.plans[index].status : updates.status;
|
|
198
|
+
if (!VALID_STATUSES.has(nextStatus)) throw new Error(`Invalid plan status: ${nextStatus}`);
|
|
199
|
+
store.plans[index] = {
|
|
200
|
+
...store.plans[index],
|
|
201
|
+
...updates,
|
|
202
|
+
id: store.plans[index].id,
|
|
203
|
+
name: updates.name === undefined ? store.plans[index].name : validatePlanName(updates.name),
|
|
204
|
+
status: nextStatus,
|
|
205
|
+
updatedAt: Date.now()
|
|
206
|
+
};
|
|
207
|
+
savePlanStore(workspaceRoot, store);
|
|
208
|
+
return store.plans[index];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function renamePlan(workspaceRoot, planId, nextName) {
|
|
212
|
+
const validName = validatePlanName(nextName);
|
|
213
|
+
const store = loadPlanStore(workspaceRoot);
|
|
214
|
+
if (store.plans.some(plan => plan.id !== planId && normalizeName(plan.name) === normalizeName(validName))) {
|
|
215
|
+
throw new Error(`Plan already exists: ${validName}`);
|
|
216
|
+
}
|
|
217
|
+
const index = store.plans.findIndex(plan => plan.id === planId);
|
|
218
|
+
if (index === -1) return null;
|
|
219
|
+
store.plans[index] = { ...store.plans[index], name: validName, updatedAt: Date.now() };
|
|
220
|
+
savePlanStore(workspaceRoot, store);
|
|
221
|
+
return store.plans[index];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function deletePlan(workspaceRoot, planId) {
|
|
225
|
+
const store = loadPlanStore(workspaceRoot);
|
|
226
|
+
const next = store.plans.filter(plan => plan.id !== planId);
|
|
227
|
+
if (next.length === store.plans.length) return false;
|
|
228
|
+
store.plans = next;
|
|
229
|
+
savePlanStore(workspaceRoot, store);
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export function planStatusLines(workspaceRoot, limit = 4) {
|
|
234
|
+
const plans = listPlans(workspaceRoot);
|
|
235
|
+
const symbols = { ready: 'โ', running: 'โถ', completed: 'โ', blocked: '!' };
|
|
236
|
+
const lines = plans.slice(0, limit).map(plan => `${symbols[plan.status] || 'โ'} ${plan.name}`);
|
|
237
|
+
if (plans.length > limit) lines.push(`+${plans.length - limit} more`);
|
|
238
|
+
return lines;
|
|
239
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
|
|
3
|
+
export const RUNTIME_EVENT_TYPES = new Set([
|
|
4
|
+
'turn.started',
|
|
5
|
+
'turn.completed',
|
|
6
|
+
'turn.failed',
|
|
7
|
+
'turn.cancelled',
|
|
8
|
+
'model.started',
|
|
9
|
+
'model.delta',
|
|
10
|
+
'model.tool_call',
|
|
11
|
+
'model.completed',
|
|
12
|
+
'model.retry',
|
|
13
|
+
'tool.requested',
|
|
14
|
+
'tool.started',
|
|
15
|
+
'tool.progress',
|
|
16
|
+
'tool.completed',
|
|
17
|
+
'tool.failed',
|
|
18
|
+
'permission.requested',
|
|
19
|
+
'permission.resolved',
|
|
20
|
+
'context.compacted'
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export function createRuntimeEvents({ turnId, now = Date.now } = {}) {
|
|
24
|
+
const emitter = new EventEmitter();
|
|
25
|
+
const resolvedTurnId = turnId || `turn-${now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
26
|
+
let seq = 0;
|
|
27
|
+
let closed = false;
|
|
28
|
+
|
|
29
|
+
function emit(type, data = {}) {
|
|
30
|
+
if (closed) return null;
|
|
31
|
+
if (!RUNTIME_EVENT_TYPES.has(type)) {
|
|
32
|
+
throw new Error(`Unknown runtime event type: ${type}`);
|
|
33
|
+
}
|
|
34
|
+
const event = {
|
|
35
|
+
version: 1,
|
|
36
|
+
seq: ++seq,
|
|
37
|
+
type,
|
|
38
|
+
turnId: resolvedTurnId,
|
|
39
|
+
timestamp: now(),
|
|
40
|
+
data: data && typeof data === 'object' ? data : { value: data }
|
|
41
|
+
};
|
|
42
|
+
emitter.emit('event', event);
|
|
43
|
+
return event;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function subscribe(listener) {
|
|
47
|
+
emitter.on('event', listener);
|
|
48
|
+
return () => emitter.off('event', listener);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function close() {
|
|
52
|
+
closed = true;
|
|
53
|
+
emitter.removeAllListeners();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
turnId: resolvedTurnId,
|
|
58
|
+
emit,
|
|
59
|
+
subscribe,
|
|
60
|
+
close
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* โโ DESIGN PHILOSOPHY / ่ง่ง่ฎพ่ฎกไธไบคไบ่ง่ โโ
|
|
3
|
+
* ไธบไบๅ็จๆทๆไพๆ่ดใ้กบๆป็ไบคไบไฝ้ช๏ผDave Code ่งๅฎ๏ผ
|
|
4
|
+
* 1. ๆๆ่ๆถ็ API ่ฏทๆฑใๅคงๆไปถๅค็ๅๆฌๅฐๅทฅๅ
ท่ฐ็จ๏ผ้ฝๅฟ
้กปๅ
ทๆๅจ็ปๅ๏ผAnimated๏ผๆๅจๆ๏ผDynamic๏ผ็็ป็ซฏ UIใ
|
|
5
|
+
* 2. ไธฅ็ฆไฝฟ็จ้ๆใๅทๅฑๅผ็่ฟ็ปญ Log ่พๅบใๅฏนไบๅคๆญฅ้ชคๆไฝ๏ผๅฆๅ็ๆถๅ๏ผ๏ผๅบไฝฟ็จๅ่กๅๅฐๆดๆฐ๏ผIn-place update๏ผ็่ฟๅบฆๆกๅๅ ่ฝฝๅจ็ปใ
|
|
6
|
+
* 3. ไฝฟ็จๅฏๆๆฌ็ป็ซฏ่ฒๅฝฉ๏ผๅฆ็ฒพๅฟ่ฐ้
็ HSL/ANSI ้ข่ฒ๏ผๅๅพฎๅๅญ็ฌฆๅจ็ป๏ผๅฆ Spinner๏ผๆฅๆไพๅฎๆถ็่ง่งๅ้ฆใ
|
|
7
|
+
* ๅ็ปญ็ปดๆคไธๅ่ฝ็ปญๅๆถ๏ผๅฟ
้กปไธฅๆ ผ้ตๅฎๆญค่ฎพ่ฎก็ๅฟตใ
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
import path from 'path';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import crypto from 'crypto';
|
|
14
|
+
|
|
15
|
+
export let SESSIONS_DIR = path.join(os.homedir(), '.dave-code-sessions');
|
|
16
|
+
|
|
17
|
+
export function setSessionsDirForTesting(directory) {
|
|
18
|
+
SESSIONS_DIR = directory;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function normalizeMessage(message) {
|
|
22
|
+
if (!message || typeof message !== 'object') return null;
|
|
23
|
+
if (!['user', 'assistant', 'tool'].includes(message.role)) return null;
|
|
24
|
+
if (typeof message.content !== 'string') return null;
|
|
25
|
+
return { ...message, role: message.role, content: message.content };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function normalizeSession(session) {
|
|
29
|
+
if (!session || typeof session !== 'object' || !session.id || !Array.isArray(session.messages)) return null;
|
|
30
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(session.id))) return null;
|
|
31
|
+
const messages = session.messages.map(normalizeMessage).filter(Boolean);
|
|
32
|
+
return {
|
|
33
|
+
...session,
|
|
34
|
+
id: String(session.id),
|
|
35
|
+
title: String(session.title || 'New Chat'),
|
|
36
|
+
timestamp: Number(session.timestamp) || Date.now(),
|
|
37
|
+
messages,
|
|
38
|
+
workspaceRoot: typeof session.workspaceRoot === 'string' ? session.workspaceRoot : null,
|
|
39
|
+
activeOpenFile: typeof session.activeOpenFile === 'string' ? session.activeOpenFile : null
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function atomicWriteSession(filePath, sessionData) {
|
|
44
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(5).toString('hex')}.tmp`;
|
|
45
|
+
fs.writeFileSync(tempPath, JSON.stringify(sessionData, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
46
|
+
try {
|
|
47
|
+
fs.chmodSync(tempPath, 0o600);
|
|
48
|
+
} catch {
|
|
49
|
+
// Best effort on Windows.
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
fs.renameSync(tempPath, filePath);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (process.platform !== 'win32' || !fs.existsSync(filePath)) throw error;
|
|
55
|
+
fs.unlinkSync(filePath);
|
|
56
|
+
fs.renameSync(tempPath, filePath);
|
|
57
|
+
} finally {
|
|
58
|
+
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function ensureSessionsDir() {
|
|
63
|
+
try {
|
|
64
|
+
if (!fs.existsSync(SESSIONS_DIR)) {
|
|
65
|
+
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o700 });
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
fs.chmodSync(SESSIONS_DIR, 0o700);
|
|
69
|
+
} catch {
|
|
70
|
+
// Best effort on Windows.
|
|
71
|
+
}
|
|
72
|
+
} catch (e) {
|
|
73
|
+
// Ignore folder creation errors (handled gracefully during saves)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* List all saved chat sessions sorted by timestamp descending (newest first).
|
|
79
|
+
* @returns {Array} Array of session objects
|
|
80
|
+
*/
|
|
81
|
+
export function listSessions() {
|
|
82
|
+
ensureSessionsDir();
|
|
83
|
+
try {
|
|
84
|
+
if (!fs.existsSync(SESSIONS_DIR)) return [];
|
|
85
|
+
|
|
86
|
+
const files = fs.readdirSync(SESSIONS_DIR);
|
|
87
|
+
const sessions = [];
|
|
88
|
+
for (const file of files) {
|
|
89
|
+
if (file.endsWith('.json')) {
|
|
90
|
+
try {
|
|
91
|
+
const filePath = path.join(SESSIONS_DIR, file);
|
|
92
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
93
|
+
const session = normalizeSession(JSON.parse(content));
|
|
94
|
+
if (session && session.id) {
|
|
95
|
+
sessions.push(session);
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
// Skip invalid JSON files
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return sessions.sort((a, b) => (b.timestamp || 0) - (a.timestamp || 0));
|
|
103
|
+
} catch (e) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Save or update a session file.
|
|
110
|
+
* @param {string} sessionId Unique session identifier
|
|
111
|
+
* @param {object} sessionData Session object containing messages, title, timestamp, and workspace metadata
|
|
112
|
+
*/
|
|
113
|
+
export function saveSession(sessionId, sessionData) {
|
|
114
|
+
ensureSessionsDir();
|
|
115
|
+
try {
|
|
116
|
+
const safeId = String(sessionId || '');
|
|
117
|
+
if (!/^[A-Za-z0-9_-]+$/.test(safeId)) return false;
|
|
118
|
+
const normalized = normalizeSession({ ...sessionData, id: safeId });
|
|
119
|
+
if (!normalized) return false;
|
|
120
|
+
const filePath = path.join(SESSIONS_DIR, `${safeId}.json`);
|
|
121
|
+
atomicWriteSession(filePath, normalized);
|
|
122
|
+
return true;
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Load a session from its identifier.
|
|
130
|
+
* @param {string} sessionId Unique session identifier
|
|
131
|
+
* @returns {object|null} Loaded session or null if not found
|
|
132
|
+
*/
|
|
133
|
+
export function loadSession(sessionId) {
|
|
134
|
+
ensureSessionsDir();
|
|
135
|
+
try {
|
|
136
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(sessionId || ''))) return null;
|
|
137
|
+
const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
138
|
+
if (fs.existsSync(filePath)) {
|
|
139
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
140
|
+
return normalizeSession(JSON.parse(content));
|
|
141
|
+
}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
// Return null on parsing or read failures
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Delete a session from storage.
|
|
150
|
+
* @param {string} sessionId Unique session identifier
|
|
151
|
+
* @returns {boolean} True if successfully deleted
|
|
152
|
+
*/
|
|
153
|
+
export function deleteSession(sessionId) {
|
|
154
|
+
ensureSessionsDir();
|
|
155
|
+
try {
|
|
156
|
+
if (!/^[A-Za-z0-9_-]+$/.test(String(sessionId || ''))) return false;
|
|
157
|
+
const filePath = path.join(SESSIONS_DIR, `${sessionId}.json`);
|
|
158
|
+
if (fs.existsSync(filePath)) {
|
|
159
|
+
fs.unlinkSync(filePath);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// Ignore unlink failures
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}
|