hasina-gemini-cli 1.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/.env.example +4 -0
- package/README.md +334 -0
- package/bin/gemini-cli.js +3 -0
- package/data/sessions.json +3 -0
- package/package.json +51 -0
- package/src/app.js +285 -0
- package/src/config/env.js +93 -0
- package/src/config/gemini.js +294 -0
- package/src/index.js +23 -0
- package/src/services/chat.service.js +51 -0
- package/src/services/command.service.js +298 -0
- package/src/services/history.service.js +83 -0
- package/src/services/session.service.js +165 -0
- package/src/utils/banner.js +314 -0
- package/src/utils/file.js +57 -0
- package/src/utils/printer.js +147 -0
- package/src/utils/validators.js +67 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
const { assertCommandArgument, normalizeModelName } = require('../utils/validators');
|
|
2
|
+
|
|
3
|
+
const COMMAND_DEFINITIONS = [
|
|
4
|
+
{ usage: '/help', description: 'Show all available commands.' },
|
|
5
|
+
{ usage: '/exit', description: 'Exit the application cleanly.' },
|
|
6
|
+
{ usage: '/clear', description: 'Clear in-memory history for the current session.' },
|
|
7
|
+
{ usage: '/history', description: 'Show recent messages from the current session.' },
|
|
8
|
+
{ usage: '/models', description: 'Open a numbered model chooser.' },
|
|
9
|
+
{ usage: '/save', description: 'Persist the current session to local JSON storage.' },
|
|
10
|
+
{ usage: '/new', description: 'Start a fresh conversation session.' },
|
|
11
|
+
{ usage: '/model', description: 'Show the active Gemini model.' },
|
|
12
|
+
{ usage: '/use-model <model_name>', description: 'Switch the active model at runtime.' },
|
|
13
|
+
{ usage: '/system', description: 'Show the active system prompt.' },
|
|
14
|
+
{ usage: '/set-system <text>', description: 'Override the system prompt for this session.' },
|
|
15
|
+
{ usage: '/sessions', description: 'List saved local sessions.' },
|
|
16
|
+
{ usage: '/load <session_id>', description: 'Load a previous session from JSON storage.' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
function messageResult(level, message, extra = {}) {
|
|
20
|
+
return {
|
|
21
|
+
kind: 'message',
|
|
22
|
+
level,
|
|
23
|
+
message,
|
|
24
|
+
...extra,
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function blockResult(title, lines) {
|
|
29
|
+
return {
|
|
30
|
+
kind: 'block',
|
|
31
|
+
title,
|
|
32
|
+
lines,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function modelPickerResult(title, lines, models) {
|
|
37
|
+
return {
|
|
38
|
+
kind: 'model-picker',
|
|
39
|
+
title,
|
|
40
|
+
lines,
|
|
41
|
+
models,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function compactText(value, maxLength = 100) {
|
|
46
|
+
const singleLine = String(value).replace(/\s+/g, ' ').trim();
|
|
47
|
+
|
|
48
|
+
if (singleLine.length <= maxLength) {
|
|
49
|
+
return singleLine;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return `${singleLine.slice(0, maxLength - 3)}...`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatTokenCount(value) {
|
|
56
|
+
if (!Number.isFinite(value)) {
|
|
57
|
+
return '?';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (value >= 1000000) {
|
|
61
|
+
return `${(value / 1000000).toFixed(value % 1000000 === 0 ? 0 : 1)}M`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (value >= 1000) {
|
|
65
|
+
return `${(value / 1000).toFixed(value % 1000 === 0 ? 0 : 1)}K`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return String(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class CommandService {
|
|
72
|
+
constructor({ sessionService, provider }) {
|
|
73
|
+
this.sessionService = sessionService;
|
|
74
|
+
this.provider = provider;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
isCommand(input) {
|
|
78
|
+
return typeof input === 'string' && input.trim().startsWith('/');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
parse(input) {
|
|
82
|
+
const trimmed = input.trim();
|
|
83
|
+
const withoutSlash = trimmed.slice(1);
|
|
84
|
+
const firstSpaceIndex = withoutSlash.indexOf(' ');
|
|
85
|
+
|
|
86
|
+
if (firstSpaceIndex === -1) {
|
|
87
|
+
return {
|
|
88
|
+
name: withoutSlash.toLowerCase(),
|
|
89
|
+
rawArgs: '',
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
name: withoutSlash.slice(0, firstSpaceIndex).toLowerCase(),
|
|
95
|
+
rawArgs: withoutSlash.slice(firstSpaceIndex + 1).trim(),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async execute(input, state) {
|
|
100
|
+
try {
|
|
101
|
+
const command = this.parse(input);
|
|
102
|
+
|
|
103
|
+
switch (command.name) {
|
|
104
|
+
case 'help':
|
|
105
|
+
return blockResult(
|
|
106
|
+
'Available Commands',
|
|
107
|
+
COMMAND_DEFINITIONS.map(
|
|
108
|
+
(entry) => `${entry.usage.padEnd(28)} ${entry.description}`
|
|
109
|
+
)
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
case 'exit':
|
|
113
|
+
return messageResult('info', 'Closing Gemini Terminal.', { exit: true });
|
|
114
|
+
|
|
115
|
+
case 'clear':
|
|
116
|
+
state.historyService.reset();
|
|
117
|
+
return messageResult('success', 'Current session history cleared from memory.');
|
|
118
|
+
|
|
119
|
+
case 'history':
|
|
120
|
+
return this.handleHistory(state);
|
|
121
|
+
|
|
122
|
+
case 'models':
|
|
123
|
+
return this.handleModels(state);
|
|
124
|
+
|
|
125
|
+
case 'save':
|
|
126
|
+
return this.handleSave(state);
|
|
127
|
+
|
|
128
|
+
case 'new':
|
|
129
|
+
return this.handleNew(state);
|
|
130
|
+
|
|
131
|
+
case 'model':
|
|
132
|
+
return messageResult('info', `Active model: ${state.model}`);
|
|
133
|
+
|
|
134
|
+
case 'use-model':
|
|
135
|
+
return this.handleUseModel(state, command.rawArgs);
|
|
136
|
+
|
|
137
|
+
case 'system':
|
|
138
|
+
return blockResult('Active System Prompt', state.systemPrompt.split(/\r?\n/));
|
|
139
|
+
|
|
140
|
+
case 'set-system':
|
|
141
|
+
return this.handleSetSystem(state, command.rawArgs);
|
|
142
|
+
|
|
143
|
+
case 'sessions':
|
|
144
|
+
return this.handleSessions();
|
|
145
|
+
|
|
146
|
+
case 'load':
|
|
147
|
+
return this.handleLoad(state, command.rawArgs);
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
return messageResult(
|
|
151
|
+
'error',
|
|
152
|
+
`Unknown command "${command.name}". Use /help to see the available commands.`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return messageResult('error', error.message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
handleHistory(state) {
|
|
161
|
+
const messages = state.historyService.getRecentMessages(10);
|
|
162
|
+
|
|
163
|
+
if (messages.length === 0) {
|
|
164
|
+
return messageResult('info', 'No messages in the current session yet.');
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const lines = messages.map((message, index) => {
|
|
168
|
+
const label = message.role === 'user' ? 'You' : 'Gemini';
|
|
169
|
+
return `${String(index + 1).padStart(2, '0')}. ${label}: ${compactText(message.content, 140)}`;
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
return blockResult('Recent Session History', lines);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async handleModels(state) {
|
|
176
|
+
if (typeof this.provider.listModels !== 'function') {
|
|
177
|
+
throw new Error('This provider does not support model listing.');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const models = await this.provider.listModels({
|
|
181
|
+
currentModel: state.model,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
if (models.length === 0) {
|
|
185
|
+
return messageResult('info', 'No selectable Gemini chat models were returned by the API.');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const lines = models.map((model, index) => {
|
|
189
|
+
const tags = [];
|
|
190
|
+
|
|
191
|
+
if (model.id === state.model) {
|
|
192
|
+
tags.push('active');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (model.isLatest) {
|
|
196
|
+
tags.push('latest');
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (model.isPreview) {
|
|
200
|
+
tags.push('preview');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const header = `${String(index + 1).padStart(2, '0')}. ${model.id}${
|
|
204
|
+
tags.length ? ` [${tags.join(', ')}]` : ''
|
|
205
|
+
}`;
|
|
206
|
+
const details = `${compactText(model.displayName, 48)} | input ${formatTokenCount(
|
|
207
|
+
model.inputTokenLimit
|
|
208
|
+
)} | output ${formatTokenCount(model.outputTokenLimit)}`;
|
|
209
|
+
|
|
210
|
+
return `${header}\n ${details}`;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
return modelPickerResult('Choose a Gemini Model', lines, models);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async handleSave(state) {
|
|
217
|
+
const savedSession = await this.sessionService.saveSession({
|
|
218
|
+
id: state.sessionId,
|
|
219
|
+
createdAt: state.sessionCreatedAt,
|
|
220
|
+
model: state.model,
|
|
221
|
+
systemPrompt: state.systemPrompt,
|
|
222
|
+
messages: state.historyService.getMessages(),
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
state.sessionId = savedSession.id;
|
|
226
|
+
state.sessionCreatedAt = savedSession.createdAt;
|
|
227
|
+
|
|
228
|
+
return messageResult('success', `Session "${savedSession.id}" saved to data/sessions.json.`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async handleNew(state) {
|
|
232
|
+
state.historyService.reset();
|
|
233
|
+
state.sessionId = await this.sessionService.generateSessionId();
|
|
234
|
+
state.sessionCreatedAt = new Date().toISOString();
|
|
235
|
+
|
|
236
|
+
return messageResult(
|
|
237
|
+
'success',
|
|
238
|
+
`Started a new session "${state.sessionId}". Active model and system prompt were kept.`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async handleUseModel(state, rawArgs) {
|
|
243
|
+
const modelName = normalizeModelName(
|
|
244
|
+
assertCommandArgument(rawArgs, 'Usage: /use-model <model_name>')
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
await this.provider.validateModel(modelName);
|
|
248
|
+
state.model = modelName;
|
|
249
|
+
|
|
250
|
+
return messageResult('success', `Active model changed to "${modelName}".`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
handleSetSystem(state, rawArgs) {
|
|
254
|
+
const nextPrompt = assertCommandArgument(rawArgs, 'Usage: /set-system <text>');
|
|
255
|
+
state.systemPrompt = nextPrompt;
|
|
256
|
+
|
|
257
|
+
return messageResult('success', 'System prompt updated for the current session.');
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async handleSessions() {
|
|
261
|
+
const sessions = await this.sessionService.listSessions();
|
|
262
|
+
|
|
263
|
+
if (sessions.length === 0) {
|
|
264
|
+
return messageResult('info', 'No saved sessions yet. Use /save to persist your current chat.');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const lines = sessions.map((session) => {
|
|
268
|
+
const updatedAt = new Date(session.updatedAt).toLocaleString();
|
|
269
|
+
return [
|
|
270
|
+
`${session.id} | ${updatedAt}`,
|
|
271
|
+
` model=${session.model} | messages=${session.messageCount}`,
|
|
272
|
+
` preview=${compactText(session.preview, 100)}`,
|
|
273
|
+
].join('\n');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
return blockResult('Saved Sessions', lines);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async handleLoad(state, rawArgs) {
|
|
280
|
+
const sessionId = assertCommandArgument(rawArgs, 'Usage: /load <session_id>');
|
|
281
|
+
const session = await this.sessionService.loadSession(sessionId);
|
|
282
|
+
|
|
283
|
+
state.historyService.replaceMessages(session.messages);
|
|
284
|
+
state.sessionId = session.id;
|
|
285
|
+
state.sessionCreatedAt = session.createdAt;
|
|
286
|
+
state.model = session.model;
|
|
287
|
+
state.systemPrompt = session.systemPrompt;
|
|
288
|
+
|
|
289
|
+
return messageResult(
|
|
290
|
+
'success',
|
|
291
|
+
`Loaded session "${session.id}" with ${session.messages.length} messages and model "${session.model}".`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
module.exports = {
|
|
297
|
+
CommandService,
|
|
298
|
+
};
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const { validateHistoryMessage } = require('../utils/validators');
|
|
2
|
+
|
|
3
|
+
class HistoryService {
|
|
4
|
+
constructor({ maxMessages }) {
|
|
5
|
+
this.maxMessages = maxMessages;
|
|
6
|
+
this.messages = [];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
addMessage(role, content) {
|
|
10
|
+
const normalized = validateHistoryMessage(
|
|
11
|
+
{
|
|
12
|
+
role,
|
|
13
|
+
content,
|
|
14
|
+
},
|
|
15
|
+
this.messages.length
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
const previous = this.messages[this.messages.length - 1];
|
|
19
|
+
|
|
20
|
+
if (previous && previous.role === normalized.role) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Conversation history is invalid. Messages must alternate between user and assistant.'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
this.messages.push(normalized);
|
|
27
|
+
return this.getMessages();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
replaceMessages(messages) {
|
|
31
|
+
if (!Array.isArray(messages)) {
|
|
32
|
+
throw new Error('Conversation history must be an array of messages.');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const normalized = messages.map((message, index) => validateHistoryMessage(message, index));
|
|
36
|
+
|
|
37
|
+
normalized.forEach((message, index) => {
|
|
38
|
+
if (index === 0 && message.role !== 'user') {
|
|
39
|
+
throw new Error('Conversation history must start with a user message.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (index > 0 && normalized[index - 1].role === message.role) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'Conversation history must alternate between user and assistant messages.'
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
this.messages = normalized;
|
|
50
|
+
return this.getMessages();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
reset() {
|
|
54
|
+
this.messages = [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
count() {
|
|
58
|
+
return this.messages.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
getMessages() {
|
|
62
|
+
return this.messages.map((message) => ({ ...message }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
getRecentMessages(limit = 10) {
|
|
66
|
+
return this.getMessages().slice(-limit);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
getBoundedMessages(limit = this.maxMessages) {
|
|
70
|
+
const slice = this.messages.slice(-limit);
|
|
71
|
+
const bounded = slice[0]?.role === 'assistant' ? slice.slice(1) : slice;
|
|
72
|
+
|
|
73
|
+
return bounded.map((message) => ({ ...message }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
formatForProvider(limit = this.maxMessages) {
|
|
77
|
+
return this.getBoundedMessages(limit);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
module.exports = {
|
|
82
|
+
HistoryService,
|
|
83
|
+
};
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
const {
|
|
2
|
+
assertNonEmptyString,
|
|
3
|
+
normalizeModelName,
|
|
4
|
+
validateHistoryMessage,
|
|
5
|
+
} = require('../utils/validators');
|
|
6
|
+
const { ensureJsonFile, readJsonFile, writeJsonFile } = require('../utils/file');
|
|
7
|
+
|
|
8
|
+
function clone(value) {
|
|
9
|
+
return JSON.parse(JSON.stringify(value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeMessages(messages) {
|
|
13
|
+
if (!Array.isArray(messages)) {
|
|
14
|
+
throw new Error('Session messages must be an array.');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const normalized = messages.map((message, index) => validateHistoryMessage(message, index));
|
|
18
|
+
|
|
19
|
+
normalized.forEach((message, index) => {
|
|
20
|
+
if (index === 0 && message.role !== 'user') {
|
|
21
|
+
throw new Error('Saved session history must start with a user message.');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (index > 0 && normalized[index - 1].role === message.role) {
|
|
25
|
+
throw new Error('Saved session history must alternate between user and assistant messages.');
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return normalized;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeStoredSession(session, index) {
|
|
33
|
+
if (!session || typeof session !== 'object') {
|
|
34
|
+
throw new Error(`Session at index ${index} must be an object.`);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
id: assertNonEmptyString(session.id, `Session id at index ${index}`),
|
|
39
|
+
createdAt: assertNonEmptyString(session.createdAt, `Session createdAt at index ${index}`),
|
|
40
|
+
updatedAt: assertNonEmptyString(session.updatedAt, `Session updatedAt at index ${index}`),
|
|
41
|
+
model: normalizeModelName(session.model),
|
|
42
|
+
systemPrompt: assertNonEmptyString(
|
|
43
|
+
session.systemPrompt,
|
|
44
|
+
`Session systemPrompt at index ${index}`
|
|
45
|
+
),
|
|
46
|
+
messages: normalizeMessages(session.messages || []),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeSessionForSave(session) {
|
|
51
|
+
if (!session || typeof session !== 'object') {
|
|
52
|
+
throw new Error('Session data must be an object.');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
id: assertNonEmptyString(session.id, 'Session id'),
|
|
57
|
+
createdAt: session.createdAt ? assertNonEmptyString(session.createdAt, 'Session createdAt') : '',
|
|
58
|
+
model: normalizeModelName(session.model),
|
|
59
|
+
systemPrompt: assertNonEmptyString(session.systemPrompt, 'Session systemPrompt'),
|
|
60
|
+
messages: normalizeMessages(session.messages || []),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
class SessionService {
|
|
65
|
+
constructor({ storagePath }) {
|
|
66
|
+
this.storagePath = storagePath;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async ensureStorage() {
|
|
70
|
+
await ensureJsonFile(this.storagePath, { sessions: [] });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async readStore() {
|
|
74
|
+
await this.ensureStorage();
|
|
75
|
+
|
|
76
|
+
const data = await readJsonFile(this.storagePath);
|
|
77
|
+
|
|
78
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
79
|
+
throw new Error('Session storage is invalid. Expected an object with a "sessions" array.');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (!Array.isArray(data.sessions)) {
|
|
83
|
+
throw new Error('Session storage is invalid. Expected "sessions" to be an array.');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
sessions: data.sessions.map((session, index) => normalizeStoredSession(session, index)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async listSessions() {
|
|
92
|
+
const store = await this.readStore();
|
|
93
|
+
|
|
94
|
+
return store.sessions
|
|
95
|
+
.slice()
|
|
96
|
+
.sort((left, right) => new Date(right.updatedAt) - new Date(left.updatedAt))
|
|
97
|
+
.map((session) => ({
|
|
98
|
+
id: session.id,
|
|
99
|
+
model: session.model,
|
|
100
|
+
createdAt: session.createdAt,
|
|
101
|
+
updatedAt: session.updatedAt,
|
|
102
|
+
messageCount: session.messages.length,
|
|
103
|
+
preview:
|
|
104
|
+
session.messages.find((message) => message.role === 'user')?.content || 'No messages yet.',
|
|
105
|
+
}));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async loadSession(sessionId) {
|
|
109
|
+
const normalizedId = assertNonEmptyString(sessionId, 'Session ID');
|
|
110
|
+
const store = await this.readStore();
|
|
111
|
+
const session = store.sessions.find((item) => item.id === normalizedId);
|
|
112
|
+
|
|
113
|
+
if (!session) {
|
|
114
|
+
throw new Error(`Session "${normalizedId}" was not found. Use /sessions to list available IDs.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return clone(session);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async saveSession(session) {
|
|
121
|
+
const normalizedSession = normalizeSessionForSave(session);
|
|
122
|
+
const store = await this.readStore();
|
|
123
|
+
const existingIndex = store.sessions.findIndex((item) => item.id === normalizedSession.id);
|
|
124
|
+
const now = new Date().toISOString();
|
|
125
|
+
const existing = existingIndex >= 0 ? store.sessions[existingIndex] : null;
|
|
126
|
+
|
|
127
|
+
const record = {
|
|
128
|
+
id: normalizedSession.id,
|
|
129
|
+
createdAt: existing?.createdAt || normalizedSession.createdAt || now,
|
|
130
|
+
updatedAt: now,
|
|
131
|
+
model: normalizedSession.model,
|
|
132
|
+
systemPrompt: normalizedSession.systemPrompt,
|
|
133
|
+
messages: normalizedSession.messages,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
if (existingIndex >= 0) {
|
|
137
|
+
store.sessions[existingIndex] = record;
|
|
138
|
+
} else {
|
|
139
|
+
store.sessions.push(record);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
await writeJsonFile(this.storagePath, store);
|
|
143
|
+
return clone(record);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async generateSessionId() {
|
|
147
|
+
const store = await this.readStore();
|
|
148
|
+
const existingIds = new Set(store.sessions.map((session) => session.id));
|
|
149
|
+
let counter = store.sessions.length + 1;
|
|
150
|
+
|
|
151
|
+
while (true) {
|
|
152
|
+
const candidate = `session_${String(counter).padStart(3, '0')}`;
|
|
153
|
+
|
|
154
|
+
if (!existingIds.has(candidate)) {
|
|
155
|
+
return candidate;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
counter += 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
module.exports = {
|
|
164
|
+
SessionService,
|
|
165
|
+
};
|