aegiscode 6.4.0 → 6.5.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/README.md +35 -4
- package/package.json +1 -1
- package/scripts/predist.mjs +5 -0
- package/src/commands.js +1 -1
- package/src/config.js +8 -6
- package/src/credentials.js +17 -310
- package/src/history.js +86 -6
- package/src/shared.js +45 -0
- package/vendor/client/credentials.js +386 -0
- package/vendor/client/session-store.js +514 -0
|
@@ -0,0 +1,514 @@
|
|
|
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
|
+
/** How long a lock may be held before another writer treats it as abandoned. */
|
|
108
|
+
const LOCK_STALE_MS = 2000;
|
|
109
|
+
/** Total time a writer will wait for the lock before proceeding unlocked. */
|
|
110
|
+
const LOCK_WAIT_MS = 500;
|
|
111
|
+
|
|
112
|
+
function lockFile(dir) {
|
|
113
|
+
return `${storeFile(dir)}.lock`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Best-effort advisory lock around the read-modify-write in `mutate()`.
|
|
118
|
+
*
|
|
119
|
+
* A private store owned by one process did not need this. A *shared* one does:
|
|
120
|
+
* the desktop app and a terminal session are two long-lived processes that both
|
|
121
|
+
* rewrite this file wholesale, and interleaved read-modify-write would drop
|
|
122
|
+
* whichever turn lost the race. The lock is deliberately soft — a crash must
|
|
123
|
+
* not wedge every future write, so a lock older than LOCK_STALE_MS is stolen,
|
|
124
|
+
* and a writer that cannot get it in LOCK_WAIT_MS proceeds anyway (losing at
|
|
125
|
+
* worst the same race that exists today, rather than refusing to save).
|
|
126
|
+
*
|
|
127
|
+
* @returns {boolean} whether the lock was acquired
|
|
128
|
+
*/
|
|
129
|
+
function acquireLock(dir) {
|
|
130
|
+
const file = lockFile(dir);
|
|
131
|
+
// The lock is taken *before* the write that used to create the directory, so
|
|
132
|
+
// this is now the first thing to touch it — a first run (or a fresh
|
|
133
|
+
// AEGISCODE_HOME) would otherwise spin here forever waiting on a lock it can
|
|
134
|
+
// never create.
|
|
135
|
+
try {
|
|
136
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
141
|
+
// Bounded, so no combination of "lock vanished" / "lock unreadable" can spin
|
|
142
|
+
// this loop: a writer that cannot decide always proceeds unlocked instead.
|
|
143
|
+
const MAX_ATTEMPTS = 200;
|
|
144
|
+
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
|
|
145
|
+
try {
|
|
146
|
+
const fd = fs.openSync(file, 'wx');
|
|
147
|
+
fs.writeSync(fd, String(process.pid));
|
|
148
|
+
fs.closeSync(fd);
|
|
149
|
+
return true;
|
|
150
|
+
} catch {
|
|
151
|
+
let age = null;
|
|
152
|
+
try {
|
|
153
|
+
age = Date.now() - fs.statSync(file).mtimeMs;
|
|
154
|
+
} catch {
|
|
155
|
+
// Released between the open and the stat — try immediately.
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (age > LOCK_STALE_MS) {
|
|
159
|
+
try {
|
|
160
|
+
fs.unlinkSync(file);
|
|
161
|
+
} catch {}
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (Date.now() >= deadline) return false;
|
|
165
|
+
// Busy-wait: the critical section is a few hundred microseconds of
|
|
166
|
+
// stringify + rename, so sleeping the event loop would cost more than it
|
|
167
|
+
// saves, and this path is not on the streaming critical path.
|
|
168
|
+
const until = Date.now() + 5;
|
|
169
|
+
while (Date.now() < until) {
|
|
170
|
+
/* spin a few ms */
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function releaseLock(dir) {
|
|
178
|
+
try {
|
|
179
|
+
fs.unlinkSync(lockFile(dir));
|
|
180
|
+
} catch {}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Read-modify-write the whole store under the lock. Every mutation goes through
|
|
185
|
+
* here so the locking is one behaviour, not a thing each caller must remember.
|
|
186
|
+
*/
|
|
187
|
+
function mutate(dir, mutator) {
|
|
188
|
+
const locked = acquireLock(dir);
|
|
189
|
+
try {
|
|
190
|
+
const sessions = load(dir);
|
|
191
|
+
const result = mutator(sessions);
|
|
192
|
+
save(dir, sessions);
|
|
193
|
+
return result;
|
|
194
|
+
} finally {
|
|
195
|
+
if (locked) releaseLock(dir);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function save(dir, sessions) {
|
|
200
|
+
atomicWrite(storeFile(dir), sessions);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function trimMessages(messages) {
|
|
204
|
+
if (!Array.isArray(messages)) return [];
|
|
205
|
+
if (messages.length <= MAX_MESSAGES_PER_SESSION) return messages;
|
|
206
|
+
return messages.slice(messages.length - MAX_MESSAGES_PER_SESSION);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Create or merge a full session record. Any local write (`upsertSession` /
|
|
211
|
+
* `appendMessage` / `recordExchange`) marks the session `pending: true` — it has
|
|
212
|
+
* local content the cloud hasn't seen yet. Only `markSynced()` clears the flag,
|
|
213
|
+
* so a push that fails (offline/no key) never silently drops the session from
|
|
214
|
+
* the retry queue.
|
|
215
|
+
*/
|
|
216
|
+
function upsertSession(dir, session) {
|
|
217
|
+
const id = session && session.id;
|
|
218
|
+
if (!id) throw new Error('session.id is required');
|
|
219
|
+
return mutate(dir, (sessions) => {
|
|
220
|
+
const prev = sessions[id] || { messages: [] };
|
|
221
|
+
sessions[id] = { ...prev, ...session, id, version: STORE_VERSION };
|
|
222
|
+
if (session.pending !== false) sessions[id].pending = true;
|
|
223
|
+
if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
|
|
224
|
+
sessions[id].seq = nextSeq(sessions);
|
|
225
|
+
return sessions[id];
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Append one message to a session (crash-safe). */
|
|
230
|
+
function appendMessage(dir, sessionId, message) {
|
|
231
|
+
if (!sessionId) throw new Error('sessionId is required');
|
|
232
|
+
return mutate(dir, (sessions) => {
|
|
233
|
+
const session = sessions[sessionId] || { id: sessionId, messages: [] };
|
|
234
|
+
const messages = Array.isArray(session.messages) ? session.messages : [];
|
|
235
|
+
session.messages = trimMessages(messages.concat(message));
|
|
236
|
+
session.updatedAt = Date.now();
|
|
237
|
+
session.pending = true;
|
|
238
|
+
session.seq = nextSeq(sessions);
|
|
239
|
+
sessions[sessionId] = session;
|
|
240
|
+
return session;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Record one finished exchange (the CLI's unit of work) as a session with a
|
|
246
|
+
* user/assistant message pair. This is what makes a terminal turn visible to
|
|
247
|
+
* the desktop, which lists sessions out of this same file.
|
|
248
|
+
*
|
|
249
|
+
* `pending` defaults to **false** here, unlike `appendMessage`: the CLI keeps
|
|
250
|
+
* its own sync ledger (`cli/src/cloudsync.js` derives pending from
|
|
251
|
+
* `sync-state.json`) and pushes through `/sync`. Marking these pending would
|
|
252
|
+
* enrol every terminal session in the *desktop's* push queue as a side effect
|
|
253
|
+
* of typing in a shell, spending the account's synced-token quota without being
|
|
254
|
+
* asked. `origin` records which host wrote the record so either side can filter.
|
|
255
|
+
*
|
|
256
|
+
* @returns {object|null} the session record, or null on unwritable storage
|
|
257
|
+
*/
|
|
258
|
+
function recordExchange(dir, exchange) {
|
|
259
|
+
const e = exchange || {};
|
|
260
|
+
if (!e.sessionId) return null;
|
|
261
|
+
const id = e.sessionId;
|
|
262
|
+
return mutate(dir, (sessions) => {
|
|
263
|
+
const session = sessions[id] || { id, messages: [] };
|
|
264
|
+
const messages = Array.isArray(session.messages) ? session.messages : [];
|
|
265
|
+
const ts = e.ts || new Date().toISOString();
|
|
266
|
+
const userMessage = { role: 'user', content: e.prompt == null ? '' : String(e.prompt), ts };
|
|
267
|
+
const assistantMessage = { role: 'assistant', content: e.reply == null ? '' : String(e.reply), ts };
|
|
268
|
+
if (e.tokens) assistantMessage.tokens = e.tokens;
|
|
269
|
+
if (typeof e.costUsd === 'number') assistantMessage.costUsd = e.costUsd;
|
|
270
|
+
if (e.status) assistantMessage.status = e.status;
|
|
271
|
+
if (e.origin) {
|
|
272
|
+
userMessage.origin = e.origin;
|
|
273
|
+
assistantMessage.origin = e.origin;
|
|
274
|
+
}
|
|
275
|
+
session.messages = trimMessages(messages.concat([userMessage, assistantMessage]));
|
|
276
|
+
session.title = session.title || String(e.prompt || '').slice(0, 60);
|
|
277
|
+
if (e.cwd) session.cwd = e.cwd;
|
|
278
|
+
session.origin = e.origin || session.origin || 'unknown';
|
|
279
|
+
session.updatedAt = Date.now();
|
|
280
|
+
// Explicit, not merely "leave it unset": `listPending` treats an absent flag
|
|
281
|
+
// as pending (sessions predating the field default to queued), so an
|
|
282
|
+
// undefined here would quietly enrol every terminal session in the desktop's
|
|
283
|
+
// push queue — the exact thing the `pending: false` default is for.
|
|
284
|
+
session.pending = e.pending === true ? true : false;
|
|
285
|
+
session.seq = nextSeq(sessions);
|
|
286
|
+
sessions[id] = session;
|
|
287
|
+
return session;
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function listSessions(dir) {
|
|
292
|
+
const sessions = load(dir);
|
|
293
|
+
return Object.values(sessions)
|
|
294
|
+
.filter((s) => s && s.id)
|
|
295
|
+
.sort(
|
|
296
|
+
(a, b) =>
|
|
297
|
+
(b.updatedAt || 0) - (a.updatedAt || 0) ||
|
|
298
|
+
(b.seq || 0) - (a.seq || 0)
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function getSession(dir, id) {
|
|
303
|
+
return load(dir)[id] || null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function deleteSession(dir, id) {
|
|
307
|
+
return mutate(dir, (sessions) => {
|
|
308
|
+
delete sessions[id];
|
|
309
|
+
return { ok: true };
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Clear the pending flag after a successful cloud push. `remote.remoteId`,
|
|
314
|
+
* when the server assigns its own conversation id, is stashed alongside. */
|
|
315
|
+
function markSynced(dir, id, remote) {
|
|
316
|
+
return mutate(dir, (sessions) => {
|
|
317
|
+
const session = sessions[id];
|
|
318
|
+
if (!session) return null;
|
|
319
|
+
session.pending = false;
|
|
320
|
+
session.lastSyncedAt = Date.now();
|
|
321
|
+
if (remote && remote.remoteId) session.remoteId = remote.remoteId;
|
|
322
|
+
session.seq = nextSeq(sessions);
|
|
323
|
+
return session;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Force a session back into the retry queue (e.g. a push that partially failed). */
|
|
328
|
+
function markPending(dir, id) {
|
|
329
|
+
return mutate(dir, (sessions) => {
|
|
330
|
+
const session = sessions[id];
|
|
331
|
+
if (!session) return null;
|
|
332
|
+
session.pending = true;
|
|
333
|
+
session.seq = nextSeq(sessions);
|
|
334
|
+
return session;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Sessions with local content the cloud hasn't confirmed yet (including
|
|
339
|
+
* sessions predating this field, which default to pending). */
|
|
340
|
+
function listPending(dir) {
|
|
341
|
+
return listSessions(dir).filter((s) => s.pending !== false);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Merge remote conversation-sync records into the local store (pull half of
|
|
346
|
+
* sync). Last-write-wins by `updatedAt`, but a local session with unsynced
|
|
347
|
+
* edits (`pending`) always wins over the remote copy — it will overwrite the
|
|
348
|
+
* remote copy on the next push instead.
|
|
349
|
+
*/
|
|
350
|
+
function mergeRemoteSessions(dir, remoteSessions) {
|
|
351
|
+
const list = Array.isArray(remoteSessions) ? remoteSessions : [];
|
|
352
|
+
if (!list.length) return 0;
|
|
353
|
+
return mutate(dir, (sessions) => {
|
|
354
|
+
let merged = 0;
|
|
355
|
+
for (const remote of list) {
|
|
356
|
+
const id = remote && (remote.session_id || remote.id);
|
|
357
|
+
if (!id) continue;
|
|
358
|
+
const local = sessions[id];
|
|
359
|
+
const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
|
|
360
|
+
if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
sessions[id] = {
|
|
364
|
+
id,
|
|
365
|
+
title: remote.title || (local && local.title) || '',
|
|
366
|
+
messages: Array.isArray(remote.messages) ? remote.messages : [],
|
|
367
|
+
updatedAt: remoteUpdatedAt || Date.now(),
|
|
368
|
+
pending: false,
|
|
369
|
+
lastSyncedAt: Date.now(),
|
|
370
|
+
remoteId: remote.session_id || remote.id,
|
|
371
|
+
origin: remote.source || (local && local.origin) || 'cloud',
|
|
372
|
+
version: STORE_VERSION,
|
|
373
|
+
};
|
|
374
|
+
sessions[id].seq = nextSeq(sessions);
|
|
375
|
+
merged += 1;
|
|
376
|
+
}
|
|
377
|
+
return merged;
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Compact listing for a picker (`/resume`, the desktop's session list). Same
|
|
383
|
+
* field names `history.js`'s readOwnSessions produced, so the two can be
|
|
384
|
+
* concatenated without either side having to know which store a row came from.
|
|
385
|
+
*/
|
|
386
|
+
function listSummaries(dir, limit = 8) {
|
|
387
|
+
return listSessions(dir)
|
|
388
|
+
.slice(0, limit)
|
|
389
|
+
.map((s) => {
|
|
390
|
+
const messages = Array.isArray(s.messages) ? s.messages : [];
|
|
391
|
+
const first = messages.find((m) => m && m.role === 'user');
|
|
392
|
+
return {
|
|
393
|
+
id: s.id,
|
|
394
|
+
cwd: (s.cwd || '').split(/[\\/]/).filter(Boolean).pop() || '~',
|
|
395
|
+
summary: String((first && first.content) || s.title || '').slice(0, 60),
|
|
396
|
+
time: new Date(s.updatedAt || Date.now()).toISOString(),
|
|
397
|
+
own: true,
|
|
398
|
+
origin: s.origin || 'unknown',
|
|
399
|
+
messages: messages.length,
|
|
400
|
+
pending: s.pending === true,
|
|
401
|
+
dir: String(s.cwd || ''),
|
|
402
|
+
};
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Rebuild a transcript (user/assistant pairs) for a session, oldest first. */
|
|
407
|
+
function readTranscript(dir, sessionId) {
|
|
408
|
+
const session = getSession(dir, sessionId);
|
|
409
|
+
if (!session) return [];
|
|
410
|
+
return (Array.isArray(session.messages) ? session.messages : [])
|
|
411
|
+
.filter((m) => m && (m.role === 'user' || m.role === 'assistant'))
|
|
412
|
+
.map((m) => ({ role: m.role, text: m.content == null ? '' : String(m.content) }));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Import a legacy store from another directory, ONE way and one time.
|
|
417
|
+
*
|
|
418
|
+
* The desktop wrote `<userData>/sessions.json` before this store existed. On an
|
|
419
|
+
* upgrade that file is the user's entire GUI history, so the shared store
|
|
420
|
+
* adopts it — but only when the shared store has nothing to lose (missing, or
|
|
421
|
+
* empty), and only by copying: the legacy file is left where it is, so a user
|
|
422
|
+
* who downgrades still finds their sessions.
|
|
423
|
+
*
|
|
424
|
+
* @returns {{adopted:boolean, sessions:number, from:string, reason?:string}}
|
|
425
|
+
*/
|
|
426
|
+
function adopt(dir, fromDir) {
|
|
427
|
+
const from = storeFile(fromDir);
|
|
428
|
+
const exists = (() => {
|
|
429
|
+
try {
|
|
430
|
+
return fs.existsSync(from);
|
|
431
|
+
} catch {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
})();
|
|
435
|
+
if (!exists) return { adopted: false, sessions: 0, from, reason: 'no legacy store' };
|
|
436
|
+
const legacy = (() => {
|
|
437
|
+
try {
|
|
438
|
+
const parsed = JSON.parse(fs.readFileSync(from, 'utf8'));
|
|
439
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
440
|
+
} catch {
|
|
441
|
+
return {};
|
|
442
|
+
}
|
|
443
|
+
})();
|
|
444
|
+
const incoming = Object.values(legacy).filter((s) => s && s.id);
|
|
445
|
+
if (!incoming.length) return { adopted: false, sessions: 0, from, reason: 'legacy store empty' };
|
|
446
|
+
if (listSessions(dir).length) {
|
|
447
|
+
return { adopted: false, sessions: 0, from, reason: 'store already has sessions' };
|
|
448
|
+
}
|
|
449
|
+
mutate(dir, (sessions) => {
|
|
450
|
+
for (const s of incoming) {
|
|
451
|
+
// Adopted sessions keep their own history; they are already local content.
|
|
452
|
+
sessions[s.id] = { ...s, adoptedFrom: from, version: STORE_VERSION };
|
|
453
|
+
sessions[s.id].seq = nextSeq(sessions);
|
|
454
|
+
}
|
|
455
|
+
sessions.__seq = Math.max(sessions.__seq || 0, legacy.__seq || 0);
|
|
456
|
+
sessions.version = STORE_VERSION;
|
|
457
|
+
return null;
|
|
458
|
+
});
|
|
459
|
+
return { adopted: true, sessions: incoming.length, from };
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Markdown export (session export, plan: Save as.../Export session). Each
|
|
464
|
+
* message becomes a `## Role` heading followed by its content verbatim —
|
|
465
|
+
* content is never re-escaped or re-wrapped, so any code fences a message
|
|
466
|
+
* already contains (assistant replies routinely have them) survive untouched
|
|
467
|
+
* instead of being nested inside an outer fence.
|
|
468
|
+
*/
|
|
469
|
+
function toMarkdown(session) {
|
|
470
|
+
const title = (session && (session.title || session.id)) || 'session';
|
|
471
|
+
const messages = (session && Array.isArray(session.messages)) ? session.messages : [];
|
|
472
|
+
const lines = [`# ${title}`, ''];
|
|
473
|
+
for (const message of messages) {
|
|
474
|
+
const role = (message && message.role) || 'unknown';
|
|
475
|
+
const heading = role.charAt(0).toUpperCase() + role.slice(1);
|
|
476
|
+
const content = (message && (message.content || message.text)) || '';
|
|
477
|
+
lines.push(`## ${heading}`, '', content, '');
|
|
478
|
+
}
|
|
479
|
+
return lines.join('\n');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** JSON export: the session record as stored, pretty-printed. */
|
|
483
|
+
function toJson(session) {
|
|
484
|
+
return JSON.stringify(session, null, 2);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
module.exports = {
|
|
488
|
+
STORE_FILE,
|
|
489
|
+
STORE_VERSION,
|
|
490
|
+
MAX_MESSAGES_PER_SESSION,
|
|
491
|
+
storeDir,
|
|
492
|
+
storeFile,
|
|
493
|
+
lockFile,
|
|
494
|
+
LOCK_STALE_MS,
|
|
495
|
+
LOCK_WAIT_MS,
|
|
496
|
+
toEpochMs,
|
|
497
|
+
load,
|
|
498
|
+
save,
|
|
499
|
+
upsertSession,
|
|
500
|
+
appendMessage,
|
|
501
|
+
recordExchange,
|
|
502
|
+
listSessions,
|
|
503
|
+
getSession,
|
|
504
|
+
deleteSession,
|
|
505
|
+
markSynced,
|
|
506
|
+
markPending,
|
|
507
|
+
listPending,
|
|
508
|
+
mergeRemoteSessions,
|
|
509
|
+
listSummaries,
|
|
510
|
+
readTranscript,
|
|
511
|
+
adopt,
|
|
512
|
+
toMarkdown,
|
|
513
|
+
toJson,
|
|
514
|
+
};
|