aegiscode 6.3.2 → 6.5.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.
@@ -0,0 +1,418 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * session-store.js — the ONE local session/memory store shared by every AEGIS
5
+ * host.
6
+ *
7
+ * Before this module the repo had two private stores:
8
+ *
9
+ * CLI ~/.aegiscode/history.jsonl one line per exchange
10
+ * desktop <userData>/sessions.json one record per session
11
+ *
12
+ * Both hosts pushed "the same wire shape" to the cloud, so a session started in
13
+ * the GUI reached the terminal only after a network round trip — and a CLI
14
+ * session could not reach the GUI at all unless the account had cloud sync on.
15
+ * Offline, or on an account with no key, the two hosts simply could not see each
16
+ * other's work.
17
+ *
18
+ * This module is the single store they now both read and write:
19
+ *
20
+ * <dir>/sessions.json { __seq, "<id>": { id, title, messages[], ... } }
21
+ *
22
+ * `dir` is resolved by `aegisHome()` (client/credentials.js) — `$AEGISCODE_HOME`
23
+ * or `~/.aegiscode` — so the desktop's *sessions* now live beside the CLI's and
24
+ * the MCP plugin's view of the same account, while the desktop keeps its
25
+ * settings (safeStorage-encrypted key, window state) in Electron's userData
26
+ * where they belong.
27
+ *
28
+ * The record shape is the desktop's, extended — not replaced. Every field the
29
+ * existing store wrote (`pending`, `seq`, `updatedAt`, `remoteId`,
30
+ * `lastSyncedAt`, `__seq`) keeps its meaning, because the sync surface and the
31
+ * renderer's session list read them.
32
+ *
33
+ * Pure Node + injectable dir, so it unit-tests without Electron or a network.
34
+ */
35
+
36
+ const fs = require('node:fs');
37
+ const path = require('node:path');
38
+ const { aegisHome } = require('./credentials.js');
39
+
40
+ const STORE_FILE = 'sessions.json';
41
+ const STORE_VERSION = 2;
42
+ /** Bound on a single session's transcript, so one runaway session cannot make
43
+ * every later load of the store O(huge). Oldest messages drop first. */
44
+ const MAX_MESSAGES_PER_SESSION = 2000;
45
+
46
+ /** Where the store lives when the caller doesn't name a dir. */
47
+ function storeDir(dir) {
48
+ return dir || aegisHome();
49
+ }
50
+
51
+ function storeFile(dir) {
52
+ return path.join(storeDir(dir), STORE_FILE);
53
+ }
54
+
55
+ /**
56
+ * Monotonic write counter stored in the file's `__seq` field. Every mutation
57
+ * bumps it, so sessions written in the same millisecond still order
58
+ * deterministically (listSessions sorts by updatedAt, then by seq).
59
+ */
60
+ function nextSeq(sessions) {
61
+ const seq = (typeof sessions.__seq === 'number' ? sessions.__seq : 0) + 1;
62
+ sessions.__seq = seq;
63
+ return seq;
64
+ }
65
+
66
+ /**
67
+ * Normalize a remote `updated_at`/`updatedAt` to epoch milliseconds. The cloud
68
+ * returns an ISO-8601 string (e.g. "2026-07-16T14:20:00"); the local store
69
+ * uses `Date.now()` epoch-ms numbers. Comparing the two directly coerces the
70
+ * string to NaN, which breaks last-write-wins ordering. ISO strings are
71
+ * parsed; epoch-ms numbers pass through; anything else becomes 0.
72
+ */
73
+ function toEpochMs(value) {
74
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
75
+ if (typeof value === 'string' && value.trim()) {
76
+ const ms = Date.parse(value);
77
+ if (Number.isFinite(ms)) return ms;
78
+ }
79
+ return 0;
80
+ }
81
+
82
+ function load(dir) {
83
+ try {
84
+ const parsed = JSON.parse(fs.readFileSync(storeFile(dir), 'utf8'));
85
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
86
+ } catch {}
87
+ return {};
88
+ }
89
+
90
+ /**
91
+ * Sessions as a JSON object without the `__seq` bookkeeping key. Callers that
92
+ * iterate the store (the renderer's list, `/resume`) must not meet `__seq` as
93
+ * if it were a session with an undefined id.
94
+ */
95
+ function atomicWrite(file, data) {
96
+ fs.mkdirSync(path.dirname(file), { recursive: true });
97
+ const tmp = `${file}.tmp-${process.pid}`;
98
+ // 0600: a transcript is private conversation content, and this file now holds
99
+ // every host's sessions in one place.
100
+ fs.writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 });
101
+ fs.renameSync(tmp, file);
102
+ try {
103
+ fs.chmodSync(file, 0o600);
104
+ } catch {}
105
+ }
106
+
107
+ function save(dir, sessions) {
108
+ atomicWrite(storeFile(dir), sessions);
109
+ }
110
+
111
+ function trimMessages(messages) {
112
+ if (!Array.isArray(messages)) return [];
113
+ if (messages.length <= MAX_MESSAGES_PER_SESSION) return messages;
114
+ return messages.slice(messages.length - MAX_MESSAGES_PER_SESSION);
115
+ }
116
+
117
+ /**
118
+ * Create or merge a full session record. Any local write (`upsertSession` /
119
+ * `appendMessage` / `recordExchange`) marks the session `pending: true` — it has
120
+ * local content the cloud hasn't seen yet. Only `markSynced()` clears the flag,
121
+ * so a push that fails (offline/no key) never silently drops the session from
122
+ * the retry queue.
123
+ */
124
+ function upsertSession(dir, session) {
125
+ const id = session && session.id;
126
+ if (!id) throw new Error('session.id is required');
127
+ const sessions = load(dir);
128
+ const prev = sessions[id] || { messages: [] };
129
+ sessions[id] = { ...prev, ...session, id, version: STORE_VERSION };
130
+ if (session.pending !== false) sessions[id].pending = true;
131
+ if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
132
+ sessions[id].seq = nextSeq(sessions);
133
+ save(dir, sessions);
134
+ return sessions[id];
135
+ }
136
+
137
+ /** Append one message to a session (crash-safe). */
138
+ function appendMessage(dir, sessionId, message) {
139
+ if (!sessionId) throw new Error('sessionId is required');
140
+ const sessions = load(dir);
141
+ const session = sessions[sessionId] || { id: sessionId, messages: [] };
142
+ const messages = Array.isArray(session.messages) ? session.messages : [];
143
+ session.messages = trimMessages(messages.concat(message));
144
+ session.updatedAt = Date.now();
145
+ session.pending = true;
146
+ session.seq = nextSeq(sessions);
147
+ sessions[sessionId] = session;
148
+ save(dir, sessions);
149
+ return session;
150
+ }
151
+
152
+ /**
153
+ * Record one finished exchange (the CLI's unit of work) as a session with a
154
+ * user/assistant message pair. This is what makes a terminal turn visible to
155
+ * the desktop, which lists sessions out of this same file.
156
+ *
157
+ * `pending` defaults to **false** here, unlike `appendMessage`: the CLI keeps
158
+ * its own sync ledger (`cli/src/cloudsync.js` derives pending from
159
+ * `sync-state.json`) and pushes through `/sync`. Marking these pending would
160
+ * enrol every terminal session in the *desktop's* push queue as a side effect
161
+ * of typing in a shell, spending the account's synced-token quota without being
162
+ * asked. `origin` records which host wrote the record so either side can filter.
163
+ *
164
+ * @returns {object|null} the session record, or null on unwritable storage
165
+ */
166
+ function recordExchange(dir, exchange) {
167
+ const e = exchange || {};
168
+ if (!e.sessionId) return null;
169
+ const sessions = load(dir);
170
+ const id = e.sessionId;
171
+ const session = sessions[id] || { id, messages: [] };
172
+ const messages = Array.isArray(session.messages) ? session.messages : [];
173
+ const ts = e.ts || new Date().toISOString();
174
+ const userMessage = { role: 'user', content: e.prompt == null ? '' : String(e.prompt), ts };
175
+ const assistantMessage = { role: 'assistant', content: e.reply == null ? '' : String(e.reply), ts };
176
+ if (e.tokens) assistantMessage.tokens = e.tokens;
177
+ if (typeof e.costUsd === 'number') assistantMessage.costUsd = e.costUsd;
178
+ if (e.status) assistantMessage.status = e.status;
179
+ if (e.origin) {
180
+ userMessage.origin = e.origin;
181
+ assistantMessage.origin = e.origin;
182
+ }
183
+ session.messages = trimMessages(messages.concat([userMessage, assistantMessage]));
184
+ session.title = session.title || String(e.prompt || '').slice(0, 60);
185
+ if (e.cwd) session.cwd = e.cwd;
186
+ session.origin = e.origin || session.origin || 'unknown';
187
+ session.updatedAt = Date.now();
188
+ // Explicit, not merely "leave it unset": `listPending` treats an absent flag
189
+ // as pending (sessions predating the field default to queued), so an
190
+ // undefined here would quietly enrol every terminal session in the desktop's
191
+ // push queue — the exact thing the `pending: false` default is for.
192
+ session.pending = e.pending === true ? true : false;
193
+ session.seq = nextSeq(sessions);
194
+ sessions[id] = session;
195
+ save(dir, sessions);
196
+ return session;
197
+ }
198
+
199
+ function listSessions(dir) {
200
+ const sessions = load(dir);
201
+ return Object.values(sessions)
202
+ .filter((s) => s && s.id)
203
+ .sort(
204
+ (a, b) =>
205
+ (b.updatedAt || 0) - (a.updatedAt || 0) ||
206
+ (b.seq || 0) - (a.seq || 0)
207
+ );
208
+ }
209
+
210
+ function getSession(dir, id) {
211
+ return load(dir)[id] || null;
212
+ }
213
+
214
+ function deleteSession(dir, id) {
215
+ const sessions = load(dir);
216
+ delete sessions[id];
217
+ save(dir, sessions);
218
+ return { ok: true };
219
+ }
220
+
221
+ /** Clear the pending flag after a successful cloud push. `remote.remoteId`,
222
+ * when the server assigns its own conversation id, is stashed alongside. */
223
+ function markSynced(dir, id, remote) {
224
+ const sessions = load(dir);
225
+ const session = sessions[id];
226
+ if (!session) return null;
227
+ session.pending = false;
228
+ session.lastSyncedAt = Date.now();
229
+ if (remote && remote.remoteId) session.remoteId = remote.remoteId;
230
+ session.seq = nextSeq(sessions);
231
+ sessions[id] = session;
232
+ save(dir, sessions);
233
+ return session;
234
+ }
235
+
236
+ /** Force a session back into the retry queue (e.g. a push that partially failed). */
237
+ function markPending(dir, id) {
238
+ const sessions = load(dir);
239
+ const session = sessions[id];
240
+ if (!session) return null;
241
+ session.pending = true;
242
+ session.seq = nextSeq(sessions);
243
+ sessions[id] = session;
244
+ save(dir, sessions);
245
+ return session;
246
+ }
247
+
248
+ /** Sessions with local content the cloud hasn't confirmed yet (including
249
+ * sessions predating this field, which default to pending). */
250
+ function listPending(dir) {
251
+ return listSessions(dir).filter((s) => s.pending !== false);
252
+ }
253
+
254
+ /**
255
+ * Merge remote conversation-sync records into the local store (pull half of
256
+ * sync). Last-write-wins by `updatedAt`, but a local session with unsynced
257
+ * edits (`pending`) always wins over the remote copy — it will overwrite the
258
+ * remote copy on the next push instead.
259
+ */
260
+ function mergeRemoteSessions(dir, remoteSessions) {
261
+ const list = Array.isArray(remoteSessions) ? remoteSessions : [];
262
+ const sessions = load(dir);
263
+ let merged = 0;
264
+ for (const remote of list) {
265
+ const id = remote && (remote.session_id || remote.id);
266
+ if (!id) continue;
267
+ const local = sessions[id];
268
+ const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
269
+ if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
270
+ continue;
271
+ }
272
+ sessions[id] = {
273
+ id,
274
+ title: remote.title || (local && local.title) || '',
275
+ messages: Array.isArray(remote.messages) ? remote.messages : [],
276
+ updatedAt: remoteUpdatedAt || Date.now(),
277
+ pending: false,
278
+ lastSyncedAt: Date.now(),
279
+ remoteId: remote.session_id || remote.id,
280
+ origin: remote.source || (local && local.origin) || 'cloud',
281
+ version: STORE_VERSION,
282
+ };
283
+ sessions[id].seq = nextSeq(sessions);
284
+ merged += 1;
285
+ }
286
+ if (merged) save(dir, sessions);
287
+ return merged;
288
+ }
289
+
290
+ /**
291
+ * Compact listing for a picker (`/resume`, the desktop's session list). Same
292
+ * field names `history.js`'s readOwnSessions produced, so the two can be
293
+ * concatenated without either side having to know which store a row came from.
294
+ */
295
+ function listSummaries(dir, limit = 8) {
296
+ return listSessions(dir)
297
+ .slice(0, limit)
298
+ .map((s) => {
299
+ const messages = Array.isArray(s.messages) ? s.messages : [];
300
+ const first = messages.find((m) => m && m.role === 'user');
301
+ return {
302
+ id: s.id,
303
+ cwd: (s.cwd || '').split(/[\\/]/).filter(Boolean).pop() || '~',
304
+ summary: String((first && first.content) || s.title || '').slice(0, 60),
305
+ time: new Date(s.updatedAt || Date.now()).toISOString(),
306
+ own: true,
307
+ origin: s.origin || 'unknown',
308
+ messages: messages.length,
309
+ pending: s.pending === true,
310
+ dir: String(s.cwd || ''),
311
+ };
312
+ });
313
+ }
314
+
315
+ /** Rebuild a transcript (user/assistant pairs) for a session, oldest first. */
316
+ function readTranscript(dir, sessionId) {
317
+ const session = getSession(dir, sessionId);
318
+ if (!session) return [];
319
+ return (Array.isArray(session.messages) ? session.messages : [])
320
+ .filter((m) => m && (m.role === 'user' || m.role === 'assistant'))
321
+ .map((m) => ({ role: m.role, text: m.content == null ? '' : String(m.content) }));
322
+ }
323
+
324
+ /**
325
+ * Import a legacy store from another directory, ONE way and one time.
326
+ *
327
+ * The desktop wrote `<userData>/sessions.json` before this store existed. On an
328
+ * upgrade that file is the user's entire GUI history, so the shared store
329
+ * adopts it — but only when the shared store has nothing to lose (missing, or
330
+ * empty), and only by copying: the legacy file is left where it is, so a user
331
+ * who downgrades still finds their sessions.
332
+ *
333
+ * @returns {{adopted:boolean, sessions:number, from:string, reason?:string}}
334
+ */
335
+ function adopt(dir, fromDir) {
336
+ const from = storeFile(fromDir);
337
+ const exists = (() => {
338
+ try {
339
+ return fs.existsSync(from);
340
+ } catch {
341
+ return false;
342
+ }
343
+ })();
344
+ if (!exists) return { adopted: false, sessions: 0, from, reason: 'no legacy store' };
345
+ const legacy = (() => {
346
+ try {
347
+ const parsed = JSON.parse(fs.readFileSync(from, 'utf8'));
348
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
349
+ } catch {
350
+ return {};
351
+ }
352
+ })();
353
+ const incoming = Object.values(legacy).filter((s) => s && s.id);
354
+ if (!incoming.length) return { adopted: false, sessions: 0, from, reason: 'legacy store empty' };
355
+ if (listSessions(dir).length) {
356
+ return { adopted: false, sessions: 0, from, reason: 'store already has sessions' };
357
+ }
358
+ const next = { ...legacy, __seq: legacy.__seq || 0 };
359
+ for (const s of incoming) {
360
+ // Adopted sessions keep their own history; they are already local content.
361
+ s.seq = nextSeq(next);
362
+ s.adoptedFrom = from;
363
+ }
364
+ next.version = STORE_VERSION;
365
+ save(dir, next);
366
+ return { adopted: true, sessions: incoming.length, from };
367
+ }
368
+
369
+ /**
370
+ * Markdown export (session export, plan: Save as.../Export session). Each
371
+ * message becomes a `## Role` heading followed by its content verbatim —
372
+ * content is never re-escaped or re-wrapped, so any code fences a message
373
+ * already contains (assistant replies routinely have them) survive untouched
374
+ * instead of being nested inside an outer fence.
375
+ */
376
+ function toMarkdown(session) {
377
+ const title = (session && (session.title || session.id)) || 'session';
378
+ const messages = (session && Array.isArray(session.messages)) ? session.messages : [];
379
+ const lines = [`# ${title}`, ''];
380
+ for (const message of messages) {
381
+ const role = (message && message.role) || 'unknown';
382
+ const heading = role.charAt(0).toUpperCase() + role.slice(1);
383
+ const content = (message && (message.content || message.text)) || '';
384
+ lines.push(`## ${heading}`, '', content, '');
385
+ }
386
+ return lines.join('\n');
387
+ }
388
+
389
+ /** JSON export: the session record as stored, pretty-printed. */
390
+ function toJson(session) {
391
+ return JSON.stringify(session, null, 2);
392
+ }
393
+
394
+ module.exports = {
395
+ STORE_FILE,
396
+ STORE_VERSION,
397
+ MAX_MESSAGES_PER_SESSION,
398
+ storeDir,
399
+ storeFile,
400
+ toEpochMs,
401
+ load,
402
+ save,
403
+ upsertSession,
404
+ appendMessage,
405
+ recordExchange,
406
+ listSessions,
407
+ getSession,
408
+ deleteSession,
409
+ markSynced,
410
+ markPending,
411
+ listPending,
412
+ mergeRemoteSessions,
413
+ listSummaries,
414
+ readTranscript,
415
+ adopt,
416
+ toMarkdown,
417
+ toJson,
418
+ };