aegis-desktop 0.4.5 → 0.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/lib/settings.js +15 -0
- package/lib/sync/sessions.js +70 -193
- package/main.js +50 -8
- package/package.json +1 -1
- package/vendor/credentials.js +386 -0
- package/vendor/session-store.js +514 -0
package/lib/settings.js
CHANGED
|
@@ -186,11 +186,25 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
186
186
|
return decrypt(cfg.key);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* When this host last wrote its own AEGIS key (ISO string, or null).
|
|
191
|
+
*
|
|
192
|
+
* The shared credential file records the same thing, and startup compares the
|
|
193
|
+
* two so the most recently saved key wins. Without a stamp the app cannot tell
|
|
194
|
+
* "I saved this last week" from "the user ran `aegiscode login` a minute ago
|
|
195
|
+
* with a rotated key", and would silently keep presenting the stale one.
|
|
196
|
+
*/
|
|
197
|
+
function aegisKeySavedAt() {
|
|
198
|
+
const cfg = load()[AEGIS_KEY_NAMESPACE] || {};
|
|
199
|
+
return cfg.savedAt || null;
|
|
200
|
+
}
|
|
201
|
+
|
|
189
202
|
function setAegisKey(key) {
|
|
190
203
|
const data = load();
|
|
191
204
|
data[AEGIS_KEY_NAMESPACE] = {
|
|
192
205
|
...(data[AEGIS_KEY_NAMESPACE] || {}),
|
|
193
206
|
key: key ? encrypt(key) : null,
|
|
207
|
+
savedAt: key ? new Date().toISOString() : null,
|
|
194
208
|
};
|
|
195
209
|
save(data);
|
|
196
210
|
return aegisKey();
|
|
@@ -278,6 +292,7 @@ function createSettingsStore({ dir, safeStorage } = {}) {
|
|
|
278
292
|
maskKey,
|
|
279
293
|
aegisKey,
|
|
280
294
|
aegisRawKey,
|
|
295
|
+
aegisKeySavedAt,
|
|
281
296
|
setAegisKey,
|
|
282
297
|
migrateLegacyAegisKey,
|
|
283
298
|
quickLauncherConfig,
|
package/lib/sync/sessions.js
CHANGED
|
@@ -1,219 +1,93 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* sessions.js —
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* sessions.js — the desktop's session store, now a thin binding over the
|
|
5
|
+
* SHARED store in `client/session-store.js`.
|
|
6
|
+
*
|
|
7
|
+
* This file used to be the implementation. It was the desktop's private
|
|
8
|
+
* `sessions.json`, while the terminal host kept `~/.aegiscode/history.jsonl`
|
|
9
|
+
* and the MCP plugin could see neither — three hosts, one account, three
|
|
10
|
+
* disjoint views of the conversation. The implementation now lives in
|
|
11
|
+
* `client/session-store.js`, which is the tree all three hosts already bundle,
|
|
12
|
+
* and every call here forwards to it.
|
|
13
|
+
*
|
|
14
|
+
* Two things that matter are deliberately unchanged:
|
|
15
|
+
*
|
|
16
|
+
* * the public API and its argument order (`(dir, …)` first) — `main.js`, the
|
|
17
|
+
* sync dispatch and `test/sync-sessions.test.mjs` call it by those names;
|
|
18
|
+
* * the record shape — `pending`, `seq`, `updatedAt`, `remoteId`,
|
|
19
|
+
* `lastSyncedAt`, `__seq` all keep their meaning, because cloud sync and
|
|
20
|
+
* the renderer's session list read them.
|
|
21
|
+
*
|
|
22
|
+
* The only real change is *where* `dir` points: main.js now passes the shared
|
|
23
|
+
* data dir (see `resolveStoreDir`), so a session typed in the terminal appears
|
|
24
|
+
* in the desktop's list without a network round trip.
|
|
25
|
+
*
|
|
26
|
+
* Resolution mirrors main.js's own pattern for the shared client: `client/` in
|
|
27
|
+
* a source checkout, `vendor/` in the packaged app (electron-builder cannot
|
|
28
|
+
* reach outside the app dir, so predist.mjs stages the file).
|
|
8
29
|
*/
|
|
9
30
|
|
|
10
|
-
const
|
|
11
|
-
const path = require('node:path');
|
|
12
|
-
|
|
13
|
-
function sessionsFile(dir) {
|
|
14
|
-
return path.join(dir, 'sessions.json');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function load(dir) {
|
|
31
|
+
const shared = (() => {
|
|
18
32
|
try {
|
|
19
|
-
return
|
|
33
|
+
return require('../../../client/session-store.js');
|
|
20
34
|
} catch {
|
|
21
|
-
return
|
|
35
|
+
return require('../../vendor/session-store.js');
|
|
22
36
|
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Monotonic write counter stored in the file's `__seq` field. Every mutation
|
|
27
|
-
* bumps it, so sessions written in the same millisecond still order
|
|
28
|
-
* deterministically (listSessions sorts by updatedAt, then by seq).
|
|
29
|
-
*/
|
|
30
|
-
function nextSeq(sessions) {
|
|
31
|
-
const seq = (typeof sessions.__seq === 'number' ? sessions.__seq : 0) + 1;
|
|
32
|
-
sessions.__seq = seq;
|
|
33
|
-
return seq;
|
|
34
|
-
}
|
|
37
|
+
})();
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
* parsed; epoch-ms numbers pass through; anything else becomes 0.
|
|
42
|
-
*/
|
|
43
|
-
function toEpochMs(value) {
|
|
44
|
-
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
45
|
-
if (typeof value === 'string' && value.trim()) {
|
|
46
|
-
const ms = Date.parse(value);
|
|
47
|
-
if (Number.isFinite(ms)) return ms;
|
|
39
|
+
const credentials = (() => {
|
|
40
|
+
try {
|
|
41
|
+
return require('../../../client/credentials.js');
|
|
42
|
+
} catch {
|
|
43
|
+
return require('../../vendor/credentials.js');
|
|
48
44
|
}
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function atomicWrite(file, data) {
|
|
53
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
54
|
-
const tmp = `${file}.tmp-${process.pid}`;
|
|
55
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
56
|
-
fs.renameSync(tmp, file);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function save(dir, sessions) {
|
|
60
|
-
atomicWrite(sessionsFile(dir), sessions);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* Create or merge a full session record. Any local write (`upsertSession` /
|
|
65
|
-
* `appendMessage`) marks the session `pending: true` — it has local content
|
|
66
|
-
* the cloud hasn't seen yet. Only `markSynced()` clears the flag, so a push
|
|
67
|
-
* that fails (offline/no key) never silently drops the session from the
|
|
68
|
-
* retry queue.
|
|
69
|
-
*/
|
|
70
|
-
function upsertSession(dir, session) {
|
|
71
|
-
const id = session && session.id;
|
|
72
|
-
if (!id) throw new Error('session.id is required');
|
|
73
|
-
const sessions = load(dir);
|
|
74
|
-
const prev = sessions[id] || { messages: [] };
|
|
75
|
-
sessions[id] = { ...prev, ...session, id, pending: true };
|
|
76
|
-
if (!sessions[id].updatedAt) sessions[id].updatedAt = Date.now();
|
|
77
|
-
sessions[id].seq = nextSeq(sessions);
|
|
78
|
-
save(dir, sessions);
|
|
79
|
-
return sessions[id];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Append one message to a session (crash-safe). */
|
|
83
|
-
function appendMessage(dir, sessionId, message) {
|
|
84
|
-
if (!sessionId) throw new Error('sessionId is required');
|
|
85
|
-
const sessions = load(dir);
|
|
86
|
-
const session = sessions[sessionId] || { id: sessionId, messages: [] };
|
|
87
|
-
const messages = Array.isArray(session.messages) ? session.messages : [];
|
|
88
|
-
session.messages = messages.concat(message);
|
|
89
|
-
session.updatedAt = Date.now();
|
|
90
|
-
session.pending = true;
|
|
91
|
-
session.seq = nextSeq(sessions);
|
|
92
|
-
sessions[sessionId] = session;
|
|
93
|
-
save(dir, sessions);
|
|
94
|
-
return session;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function listSessions(dir) {
|
|
98
|
-
const sessions = load(dir);
|
|
99
|
-
return Object.values(sessions)
|
|
100
|
-
.filter((s) => s && s.id)
|
|
101
|
-
.sort(
|
|
102
|
-
(a, b) =>
|
|
103
|
-
(b.updatedAt || 0) - (a.updatedAt || 0) ||
|
|
104
|
-
(b.seq || 0) - (a.seq || 0)
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function getSession(dir, id) {
|
|
109
|
-
return load(dir)[id] || null;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function deleteSession(dir, id) {
|
|
113
|
-
const sessions = load(dir);
|
|
114
|
-
delete sessions[id];
|
|
115
|
-
save(dir, sessions);
|
|
116
|
-
return { ok: true };
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Clear the pending flag after a successful cloud push. `remote.remoteId`,
|
|
120
|
-
* when the server assigns its own conversation id, is stashed alongside. */
|
|
121
|
-
function markSynced(dir, id, remote) {
|
|
122
|
-
const sessions = load(dir);
|
|
123
|
-
const session = sessions[id];
|
|
124
|
-
if (!session) return null;
|
|
125
|
-
session.pending = false;
|
|
126
|
-
session.lastSyncedAt = Date.now();
|
|
127
|
-
if (remote && remote.remoteId) session.remoteId = remote.remoteId;
|
|
128
|
-
session.seq = nextSeq(sessions);
|
|
129
|
-
sessions[id] = session;
|
|
130
|
-
save(dir, sessions);
|
|
131
|
-
return session;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Force a session back into the retry queue (e.g. a push that partially failed). */
|
|
135
|
-
function markPending(dir, id) {
|
|
136
|
-
const sessions = load(dir);
|
|
137
|
-
const session = sessions[id];
|
|
138
|
-
if (!session) return null;
|
|
139
|
-
session.pending = true;
|
|
140
|
-
session.seq = nextSeq(sessions);
|
|
141
|
-
sessions[id] = session;
|
|
142
|
-
save(dir, sessions);
|
|
143
|
-
return session;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/** Sessions with local content the cloud hasn't confirmed yet (including
|
|
147
|
-
* sessions predating this field, which default to pending). */
|
|
148
|
-
function listPending(dir) {
|
|
149
|
-
return listSessions(dir).filter((s) => s.pending !== false);
|
|
150
|
-
}
|
|
45
|
+
})();
|
|
151
46
|
|
|
152
47
|
/**
|
|
153
|
-
*
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
* remote copy on the next push instead.
|
|
48
|
+
* The directory the shared store lives in: `$AEGISCODE_HOME` or `~/.aegiscode`.
|
|
49
|
+
* `legacyDir` (Electron's userData) is only used to adopt a pre-unification
|
|
50
|
+
* store, never to read sessions from afterwards.
|
|
157
51
|
*/
|
|
158
|
-
function
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
for (const remote of list) {
|
|
163
|
-
const id = remote && (remote.session_id || remote.id);
|
|
164
|
-
if (!id) continue;
|
|
165
|
-
const local = sessions[id];
|
|
166
|
-
const remoteUpdatedAt = toEpochMs(remote.updated_at ?? remote.updatedAt);
|
|
167
|
-
if (local && (local.pending || (local.updatedAt || 0) >= remoteUpdatedAt)) {
|
|
168
|
-
continue;
|
|
169
|
-
}
|
|
170
|
-
sessions[id] = {
|
|
171
|
-
id,
|
|
172
|
-
title: remote.title || (local && local.title) || '',
|
|
173
|
-
messages: Array.isArray(remote.messages) ? remote.messages : [],
|
|
174
|
-
updatedAt: remoteUpdatedAt || Date.now(),
|
|
175
|
-
pending: false,
|
|
176
|
-
lastSyncedAt: Date.now(),
|
|
177
|
-
remoteId: remote.session_id || remote.id,
|
|
178
|
-
};
|
|
179
|
-
sessions[id].seq = nextSeq(sessions);
|
|
180
|
-
merged += 1;
|
|
181
|
-
}
|
|
182
|
-
if (merged) save(dir, sessions);
|
|
183
|
-
return merged;
|
|
52
|
+
function resolveStoreDir(legacyDir) {
|
|
53
|
+
const dir = credentials.aegisHome();
|
|
54
|
+
if (legacyDir && legacyDir !== dir) adopt(dir, legacyDir);
|
|
55
|
+
return dir;
|
|
184
56
|
}
|
|
185
57
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
58
|
+
const {
|
|
59
|
+
storeFile,
|
|
60
|
+
load,
|
|
61
|
+
save,
|
|
62
|
+
upsertSession,
|
|
63
|
+
appendMessage,
|
|
64
|
+
recordExchange,
|
|
65
|
+
listSessions,
|
|
66
|
+
getSession,
|
|
67
|
+
deleteSession,
|
|
68
|
+
markSynced,
|
|
69
|
+
markPending,
|
|
70
|
+
listPending,
|
|
71
|
+
mergeRemoteSessions,
|
|
72
|
+
listSummaries,
|
|
73
|
+
readTranscript,
|
|
74
|
+
adopt,
|
|
75
|
+
toMarkdown,
|
|
76
|
+
toJson,
|
|
77
|
+
} = shared;
|
|
205
78
|
|
|
206
|
-
/**
|
|
207
|
-
|
|
208
|
-
return JSON.stringify(session, null, 2);
|
|
209
|
-
}
|
|
79
|
+
/** Kept under its old name — main.js and the tests call `sessionsFile`. */
|
|
80
|
+
const sessionsFile = storeFile;
|
|
210
81
|
|
|
211
82
|
module.exports = {
|
|
212
83
|
sessionsFile,
|
|
84
|
+
storeFile,
|
|
85
|
+
resolveStoreDir,
|
|
213
86
|
load,
|
|
214
87
|
save,
|
|
215
88
|
upsertSession,
|
|
216
89
|
appendMessage,
|
|
90
|
+
recordExchange,
|
|
217
91
|
listSessions,
|
|
218
92
|
getSession,
|
|
219
93
|
deleteSession,
|
|
@@ -221,6 +95,9 @@ module.exports = {
|
|
|
221
95
|
markPending,
|
|
222
96
|
listPending,
|
|
223
97
|
mergeRemoteSessions,
|
|
98
|
+
listSummaries,
|
|
99
|
+
readTranscript,
|
|
100
|
+
adopt,
|
|
224
101
|
toMarkdown,
|
|
225
102
|
toJson,
|
|
226
103
|
};
|
package/main.js
CHANGED
|
@@ -36,6 +36,17 @@ try {
|
|
|
36
36
|
}
|
|
37
37
|
const { createClient } = sharedClient;
|
|
38
38
|
|
|
39
|
+
// The shared credential store (`client/credentials.js`) — the same 0600 file
|
|
40
|
+
// the terminal host writes with `aegiscode login` and the MCP plugin reads. The
|
|
41
|
+
// app resolves its own account key through it, so signing in once serves all
|
|
42
|
+
// three hosts; see resolveStartupKey() and persistApiKey() below.
|
|
43
|
+
let credentials;
|
|
44
|
+
try {
|
|
45
|
+
credentials = require('../client/credentials.js');
|
|
46
|
+
} catch {
|
|
47
|
+
credentials = require('./vendor/credentials.js');
|
|
48
|
+
}
|
|
49
|
+
|
|
39
50
|
let electron = null;
|
|
40
51
|
try {
|
|
41
52
|
// In plain Node (CI smoke test, `node --check`) this either throws
|
|
@@ -666,14 +677,24 @@ function resolveUserDataDir(app) {
|
|
|
666
677
|
* Wire the real LocalEngine registry: settings store + ollama/providers
|
|
667
678
|
* transports + the shared cloud client. Transport-only — no brain logic.
|
|
668
679
|
*/
|
|
669
|
-
function createEngine(aegis, { app, safeStorage, dir: dirOverride } = {}) {
|
|
670
|
-
|
|
671
|
-
|
|
680
|
+
function createEngine(aegis, { app, safeStorage, dir: dirOverride, sessionsDir: sessionsOverride } = {}) {
|
|
681
|
+
// Settings (the safeStorage-encrypted key, window state, provider entries)
|
|
682
|
+
// stay in Electron's userData: they are this host's own, and the key is only
|
|
683
|
+
// readable by this process.
|
|
684
|
+
const settingsDir = dirOverride || resolveUserDataDir(app);
|
|
685
|
+
const settings = createSettingsStore({ dir: settingsDir, safeStorage });
|
|
672
686
|
// Relocate a pre-fix `settings['aegis']` key into the reserved namespace so
|
|
673
687
|
// it stops showing up as a provider. Ciphertext-level, so safe pre-'ready'.
|
|
674
688
|
settings.migrateLegacyAegisKey();
|
|
675
689
|
const engine = createLocalEngine({ aegis, settings, ollama, providers });
|
|
676
|
-
|
|
690
|
+
// Sessions/memory live in the SHARED data dir ($AEGISCODE_HOME or
|
|
691
|
+
// ~/.aegiscode) — the same file the terminal host and the MCP plugin read —
|
|
692
|
+
// so a thread started in either shows up in the other without cloud sync and
|
|
693
|
+
// without a key. resolveStoreDir() also adopts a pre-unification
|
|
694
|
+
// userData/sessions.json, once, when the shared store is still empty, so an
|
|
695
|
+
// upgrade does not look like every conversation was deleted.
|
|
696
|
+
const sessionsDir = sessionsOverride || sessionStore.resolveStoreDir(settingsDir);
|
|
697
|
+
return { engine, sessionsDir, settingsDir, settings };
|
|
677
698
|
}
|
|
678
699
|
|
|
679
700
|
/**
|
|
@@ -1233,7 +1254,7 @@ function bootstrap() {
|
|
|
1233
1254
|
else pendingDeepLink = parsed;
|
|
1234
1255
|
});
|
|
1235
1256
|
|
|
1236
|
-
const aegis = createClient();
|
|
1257
|
+
const aegis = createClient(credentials.clientOptions());
|
|
1237
1258
|
const dataDir = resolveUserDataDir(app);
|
|
1238
1259
|
|
|
1239
1260
|
// One settings store backs both the provider settings surface and the AEGIS
|
|
@@ -1248,7 +1269,17 @@ function bootstrap() {
|
|
|
1248
1269
|
// Persist the in-app AEGIS key in its own reserved namespace, encrypted —
|
|
1249
1270
|
// never as a provider named 'aegis' (that coupling let the Settings pane's
|
|
1250
1271
|
// "Remove" delete the AEGIS key; defect #1).
|
|
1251
|
-
|
|
1272
|
+
//
|
|
1273
|
+
// Mirrored into the shared 0600 store as well, so one sign-in covers this app,
|
|
1274
|
+
// `aegiscode` and the MCP plugin instead of each host needing its own. The
|
|
1275
|
+
// app's copy stays the encrypted one; resolveStartupKey() compares their
|
|
1276
|
+
// `savedAt` stamps so whichever host wrote last is the key in force.
|
|
1277
|
+
const persistApiKey = (key) => {
|
|
1278
|
+
const result = settings.setAegisKey(key);
|
|
1279
|
+
if (key) credentials.saveApiKey(key);
|
|
1280
|
+
else credentials.clearApiKey();
|
|
1281
|
+
return result;
|
|
1282
|
+
};
|
|
1252
1283
|
|
|
1253
1284
|
// Native "reply ready" notification: main.js is exactly where every
|
|
1254
1285
|
// chatCompletion/model:chat call already resolves (withReplyNotify above),
|
|
@@ -1565,8 +1596,19 @@ function bootstrap() {
|
|
|
1565
1596
|
// Hydrate the client from the persisted key BEFORE the renderer issues any
|
|
1566
1597
|
// status/model call, so class/model dropdowns populate immediately when a
|
|
1567
1598
|
// key was saved in-app (safeStorage is usable only after app ready).
|
|
1568
|
-
|
|
1569
|
-
|
|
1599
|
+
//
|
|
1600
|
+
// `$AEGIS_API_KEY` still outranks both stores: createClient() already read
|
|
1601
|
+
// it, and nothing here may shadow an explicit export.
|
|
1602
|
+
const envKey = credentials.normalizeApiKey(process.env[credentials.KEY_ENV]);
|
|
1603
|
+
if (!envKey) {
|
|
1604
|
+
const chosen = credentials.preferNewest(settings.aegisRawKey(), settings.aegisKeySavedAt());
|
|
1605
|
+
if (chosen.key) {
|
|
1606
|
+
aegis.setApiKey(chosen.key);
|
|
1607
|
+
// The shared store is the newer write: bring this host's encrypted copy
|
|
1608
|
+
// up to date so a CLI rotation does not have to be redone in Settings.
|
|
1609
|
+
if (chosen.source === 'shared') settings.setAegisKey(chosen.key);
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1570
1612
|
// Apply whatever was last saved (or the default): ship the shortcut only
|
|
1571
1613
|
// when app.isPackaged || the settings flag is on — see
|
|
1572
1614
|
// shouldEnableGlobalShortcut — so a plain `electron .` dev run never
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aegis-desktop",
|
|
3
3
|
"productName": "AEGIS Desktop",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.5.1",
|
|
5
5
|
"description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "AEGIS Code",
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* credentials.js — the ONE AEGIS account credential store, shared by every
|
|
5
|
+
* host in this repo (terminal `aegiscode`, the MCP plugin, the desktop app).
|
|
6
|
+
*
|
|
7
|
+
* Why it lives in `client/`: this directory is the only tree all three hosts
|
|
8
|
+
* already bundle. The MCP plugin ships `mcp/` + `client/` and nothing else, so
|
|
9
|
+
* a reader placed here needs no cross-package dependency — which is what the
|
|
10
|
+
* alternative (the MCP host requiring `cli/src/credentials.js`) would have
|
|
11
|
+
* forced, and why that host used to read `AEGIS_API_KEY` from the environment
|
|
12
|
+
* alone while the CLI could save a key to disk. One login, one file, three
|
|
13
|
+
* hosts: that is the point of this module.
|
|
14
|
+
*
|
|
15
|
+
* Zero dependencies, no host imports. The data dir is resolved here
|
|
16
|
+
* (`aegisHome()`) rather than imported from the CLI's config.js, so this file
|
|
17
|
+
* runs from a bare `node client/credentials.js` inside the plugin tree.
|
|
18
|
+
*
|
|
19
|
+
* Resolution order, first hit wins:
|
|
20
|
+
*
|
|
21
|
+
* 1. `AEGIS_API_KEY` — the environment, so CI and an explicit export keep
|
|
22
|
+
* working and nothing written here can shadow them.
|
|
23
|
+
* 2. `credentials.json` in the data dir, mode 0600. The writable store.
|
|
24
|
+
* 3. `config.json`'s `aegiscloud.api_key` / `memory.token` — the shape an
|
|
25
|
+
* earlier AEGIS CLI left in the same data dir. Read, never written, and
|
|
26
|
+
* never deleted: it is another product's file and the user's key is in
|
|
27
|
+
* it. When one is found it is *also* copied into credentials.json so the
|
|
28
|
+
* next run reads the 0600 copy, and the status line says the plaintext
|
|
29
|
+
* copy is still there rather than silently leaving it.
|
|
30
|
+
*
|
|
31
|
+
* The key is never printed in full by anything in this repo; callers mask it.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const fs = require('node:fs');
|
|
35
|
+
const os = require('node:os');
|
|
36
|
+
const path = require('node:path');
|
|
37
|
+
|
|
38
|
+
const KEY_ENV = 'AEGIS_API_KEY';
|
|
39
|
+
/** Optional override for the memory token (cloud sync's own credential). */
|
|
40
|
+
const MEMORY_ENV = 'AEGIS_MEMORY_TOKEN';
|
|
41
|
+
const HOME_ENV = 'AEGISCODE_HOME';
|
|
42
|
+
const CREDENTIALS_FILE = 'credentials.json';
|
|
43
|
+
const CONFIG_FILE = 'config.json';
|
|
44
|
+
const FILE_MODE = 0o600;
|
|
45
|
+
const DIR_MODE = 0o700;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The per-user AEGIS data directory: `$AEGISCODE_HOME` or `~/.aegiscode`.
|
|
49
|
+
*
|
|
50
|
+
* Every host resolves its data dir through this one function, so the CLI, the
|
|
51
|
+
* MCP plugin and the desktop app agree on where memory and credentials live
|
|
52
|
+
* instead of each keeping a private store the others cannot see. (The desktop
|
|
53
|
+
* still keeps its *settings* — the safeStorage-encrypted key, window state —
|
|
54
|
+
* in Electron's userData; only the shared memory + credential files live here.)
|
|
55
|
+
*/
|
|
56
|
+
function aegisHome(env) {
|
|
57
|
+
const e = env || process.env;
|
|
58
|
+
const override = e && e[HOME_ENV];
|
|
59
|
+
if (override && String(override).trim()) return path.resolve(String(override).trim());
|
|
60
|
+
return path.join(os.homedir(), '.aegiscode');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function credentialsPath(dir) {
|
|
64
|
+
return path.join(dir || aegisHome(), CREDENTIALS_FILE);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function configPath(dir) {
|
|
68
|
+
return path.join(dir || aegisHome(), CONFIG_FILE);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Accept the key in the shapes a user actually pastes it.
|
|
73
|
+
*
|
|
74
|
+
* Copy-paste from a dashboard, a `.env` line, a shell profile or a chat message
|
|
75
|
+
* are all realistic — and pasting `AEGIS_API_KEY=aegis_…` or `"aegis_…"` into a
|
|
76
|
+
* prompt that stores the literal string produces an auth failure the user
|
|
77
|
+
* cannot see the cause of, because the key *looks* right in the status line.
|
|
78
|
+
*/
|
|
79
|
+
function normalizeApiKey(raw) {
|
|
80
|
+
let s = String(raw == null ? '' : raw).trim();
|
|
81
|
+
if (!s) return '';
|
|
82
|
+
s = s.replace(/^export\s+/i, '').trim();
|
|
83
|
+
const assignment = /^[A-Za-z_][A-Za-z0-9_]*\s*=\s*(.+)$/s.exec(s);
|
|
84
|
+
if (assignment) s = assignment[1].trim();
|
|
85
|
+
s = s.replace(/^["']|["']$/g, '').trim();
|
|
86
|
+
s = s.replace(/^Bearer\s+/i, '').trim();
|
|
87
|
+
return s;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Shape check only — no network. Catches the two mistakes that are structural
|
|
92
|
+
* (an empty paste, and a wrapped/truncated multi-line paste) so the caller can
|
|
93
|
+
* refuse before spending a verification round trip.
|
|
94
|
+
*/
|
|
95
|
+
function validateApiKey(raw) {
|
|
96
|
+
const key = normalizeApiKey(raw);
|
|
97
|
+
if (!key) return { ok: false, key, reason: 'empty', message: 'no key given' };
|
|
98
|
+
if (/\s/.test(key)) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
key,
|
|
102
|
+
reason: 'whitespace',
|
|
103
|
+
message: 'that looks like more than one word — paste just the key',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (key.length < 16) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
key,
|
|
110
|
+
reason: 'too short',
|
|
111
|
+
message: `that key is ${key.length} characters — AEGIS keys are longer than that`,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
return { ok: true, key, reason: null, message: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The stored credential object, or {} — never throws on a missing/corrupt file. */
|
|
118
|
+
function readCredentials(dir) {
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(fs.readFileSync(credentialsPath(dir), 'utf8'));
|
|
121
|
+
if (parsed && typeof parsed === 'object') return parsed;
|
|
122
|
+
} catch {}
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Merge a patch into the store, creating it 0600 (and tightening an existing
|
|
128
|
+
* file that is wider — a key file that a previous run left 0644 is exactly the
|
|
129
|
+
* leak this file exists to avoid).
|
|
130
|
+
*/
|
|
131
|
+
function writeCredentials(patch, dir) {
|
|
132
|
+
const target = credentialsPath(dir);
|
|
133
|
+
const next = { ...readCredentials(dir), ...patch, version: 1 };
|
|
134
|
+
try {
|
|
135
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: DIR_MODE });
|
|
136
|
+
const tmp = target + '.tmp';
|
|
137
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', { mode: FILE_MODE });
|
|
138
|
+
fs.renameSync(tmp, target);
|
|
139
|
+
try {
|
|
140
|
+
fs.chmodSync(target, FILE_MODE);
|
|
141
|
+
} catch {}
|
|
142
|
+
} catch (e) {
|
|
143
|
+
if (process.env.AEGIS_HIST_DEBUG) console.error('[credentials] write failed:', e);
|
|
144
|
+
return { ok: false, error: e, credentials: next };
|
|
145
|
+
}
|
|
146
|
+
return { ok: true, credentials: next };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The key an earlier AEGIS CLI wrote into config.json (the `aegiscloud` /
|
|
151
|
+
* `memory` blocks are not ours). Read-only.
|
|
152
|
+
*/
|
|
153
|
+
function readLegacyConfig(dir) {
|
|
154
|
+
try {
|
|
155
|
+
const parsed = JSON.parse(fs.readFileSync(configPath(dir), 'utf8'));
|
|
156
|
+
if (!parsed || typeof parsed !== 'object') return {};
|
|
157
|
+
const cloud = parsed.aegiscloud && typeof parsed.aegiscloud === 'object' ? parsed.aegiscloud : {};
|
|
158
|
+
const memory = parsed.memory && typeof parsed.memory === 'object' ? parsed.memory : {};
|
|
159
|
+
return {
|
|
160
|
+
apiKey: normalizeApiKey(cloud.api_key),
|
|
161
|
+
memoryToken: String(memory.token || '').trim(),
|
|
162
|
+
syncConversations: cloud.syncConversations === true ? true : undefined,
|
|
163
|
+
lastVerified: cloud.lastVerified || null,
|
|
164
|
+
memorySubscribed: memory.subscribed === true ? true : undefined,
|
|
165
|
+
};
|
|
166
|
+
} catch {
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** True when config.json still carries a plaintext copy of the account key. */
|
|
172
|
+
function legacyKeyOnDisk(dir) {
|
|
173
|
+
return !!readLegacyConfig(dir).apiKey;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Copy a legacy key/token into the 0600 store, once, without touching the file
|
|
178
|
+
* it came from. Returns which fields were adopted so the caller can say so.
|
|
179
|
+
*/
|
|
180
|
+
function adoptLegacy(dir) {
|
|
181
|
+
const legacy = readLegacyConfig(dir);
|
|
182
|
+
const creds = readCredentials(dir);
|
|
183
|
+
const patch = {};
|
|
184
|
+
if (legacy.apiKey && !creds.aegisApiKey) patch.aegisApiKey = legacy.apiKey;
|
|
185
|
+
if (legacy.memoryToken && !creds.memoryToken) patch.memoryToken = legacy.memoryToken;
|
|
186
|
+
if (!Object.keys(patch).length) return { adopted: [] };
|
|
187
|
+
const written = writeCredentials({ ...patch, adoptedFrom: configPath(dir) }, dir);
|
|
188
|
+
if (!written.ok) return { adopted: [] };
|
|
189
|
+
return { adopted: Object.keys(patch) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The key to use, and where it came from.
|
|
194
|
+
*
|
|
195
|
+
* @returns {{key:string, source:'env'|'credentials'|'config'|'none', from:string}}
|
|
196
|
+
*/
|
|
197
|
+
function resolveApiKey(o = {}) {
|
|
198
|
+
const env = o.env || process.env;
|
|
199
|
+
const dir = o.dir;
|
|
200
|
+
const fromEnv = normalizeApiKey(env && env[KEY_ENV]);
|
|
201
|
+
if (fromEnv) return { key: fromEnv, source: 'env', from: KEY_ENV };
|
|
202
|
+
|
|
203
|
+
const creds = readCredentials(dir);
|
|
204
|
+
const stored = normalizeApiKey(creds.aegisApiKey);
|
|
205
|
+
if (stored) return { key: stored, source: 'credentials', from: credentialsPath(dir) };
|
|
206
|
+
|
|
207
|
+
const legacy = readLegacyConfig(dir).apiKey;
|
|
208
|
+
if (legacy) return { key: legacy, source: 'config', from: configPath(dir) };
|
|
209
|
+
|
|
210
|
+
return { key: '', source: 'none', from: '' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Whether any credential is available (env, store or legacy config). */
|
|
214
|
+
function hasApiKey(o = {}) {
|
|
215
|
+
return !!resolveApiKey(o).key;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Persist a key.
|
|
220
|
+
*
|
|
221
|
+
* @returns {{ok:boolean, key:string, path:string, error?:Error, message?:string}}
|
|
222
|
+
*/
|
|
223
|
+
function saveApiKey(raw, dir) {
|
|
224
|
+
const v = validateApiKey(raw);
|
|
225
|
+
if (!v.ok) return { ok: false, key: v.key, path: credentialsPath(dir), message: v.message };
|
|
226
|
+
const written = writeCredentials({ aegisApiKey: v.key, savedAt: new Date().toISOString() }, dir);
|
|
227
|
+
if (!written.ok) {
|
|
228
|
+
return {
|
|
229
|
+
ok: false,
|
|
230
|
+
key: v.key,
|
|
231
|
+
path: credentialsPath(dir),
|
|
232
|
+
error: written.error,
|
|
233
|
+
message: `could not write ${credentialsPath(dir)}`,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, key: v.key, path: credentialsPath(dir) };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Remove the stored key (and nothing else — the memory token stays, since a
|
|
241
|
+
* key rotation should not silently unsubscribe cloud memory). config.json is
|
|
242
|
+
* never touched: another product writes it.
|
|
243
|
+
*/
|
|
244
|
+
function clearApiKey(dir) {
|
|
245
|
+
const had = !!normalizeApiKey(readCredentials(dir).aegisApiKey);
|
|
246
|
+
const written = writeCredentials({ aegisApiKey: '', adoptedFrom: '' }, dir);
|
|
247
|
+
return { cleared: had, ok: written.ok, path: credentialsPath(dir) };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The memory token cloud sync authenticates with, from store or legacy. */
|
|
251
|
+
function resolveMemoryToken(o = {}) {
|
|
252
|
+
const env = o.env || process.env;
|
|
253
|
+
const dir = o.dir;
|
|
254
|
+
const fromEnv = String((env && env[MEMORY_ENV]) || '').trim();
|
|
255
|
+
if (fromEnv) return { token: fromEnv, source: 'env' };
|
|
256
|
+
const creds = readCredentials(dir);
|
|
257
|
+
const stored = String(creds.memoryToken || '').trim();
|
|
258
|
+
if (stored) return { token: stored, source: 'credentials' };
|
|
259
|
+
const legacy = readLegacyConfig(dir).memoryToken;
|
|
260
|
+
if (legacy) return { token: legacy, source: 'config' };
|
|
261
|
+
return { token: '', source: 'none' };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function saveMemoryToken(token, extra = {}, dir) {
|
|
265
|
+
return writeCredentials({ memoryToken: String(token || '').trim(), ...extra }, dir);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function clearMemoryToken(dir) {
|
|
269
|
+
return writeCredentials({ memoryToken: '', memorySubscribed: false }, dir);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Everything a status screen or a `doctor` line needs about the credential,
|
|
274
|
+
* without ever exposing the key itself.
|
|
275
|
+
*/
|
|
276
|
+
function keyStatus(o = {}) {
|
|
277
|
+
const dir = o.dir;
|
|
278
|
+
const { key, source, from } = resolveApiKey(o);
|
|
279
|
+
const creds = readCredentials(dir);
|
|
280
|
+
let mode = null;
|
|
281
|
+
try {
|
|
282
|
+
mode = fs.statSync(credentialsPath(dir)).mode & 0o777;
|
|
283
|
+
} catch {}
|
|
284
|
+
return {
|
|
285
|
+
configured: !!key,
|
|
286
|
+
key,
|
|
287
|
+
source,
|
|
288
|
+
from,
|
|
289
|
+
path: credentialsPath(dir),
|
|
290
|
+
fileMode: mode == null ? null : '0' + mode.toString(8),
|
|
291
|
+
stored: !!normalizeApiKey(creds.aegisApiKey),
|
|
292
|
+
legacyPlaintext: legacyKeyOnDisk(dir),
|
|
293
|
+
verifiedAt: creds.verifiedAt || null,
|
|
294
|
+
account: creds.account || null,
|
|
295
|
+
memoryToken: !!resolveMemoryToken(o).token,
|
|
296
|
+
memorySource: resolveMemoryToken(o).source,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Client options for `createClient`: the resolved key plus the stored memory
|
|
302
|
+
* token, so a restart does not re-exchange for a token it already has.
|
|
303
|
+
*/
|
|
304
|
+
function clientOptions(o = {}) {
|
|
305
|
+
const { key } = resolveApiKey(o);
|
|
306
|
+
const { token } = resolveMemoryToken(o);
|
|
307
|
+
const opts = {};
|
|
308
|
+
if (key) opts.apiKey = key;
|
|
309
|
+
if (token) opts.memoryToken = token;
|
|
310
|
+
return opts;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Human label for a resolution source, for status lines. */
|
|
314
|
+
const SOURCE_LABEL = {
|
|
315
|
+
env: `${KEY_ENV}`,
|
|
316
|
+
credentials: 'saved key file',
|
|
317
|
+
config: 'config.json (aegis CLI)',
|
|
318
|
+
none: 'not set',
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
function sourceLabel(source) {
|
|
322
|
+
return SOURCE_LABEL[source] || SOURCE_LABEL.none;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Prefer the most recently saved key between a host's own copy and this file.
|
|
327
|
+
*
|
|
328
|
+
* The desktop keeps its key encrypted via Electron safeStorage, which is a
|
|
329
|
+
* better place for it than a 0600 file — but only one of the two can be in
|
|
330
|
+
* force, and picking wrong is silent: the user rotates their key with
|
|
331
|
+
* `aegiscode login`, the app keeps presenting an old string it saved last week,
|
|
332
|
+
* and every call 401s with a key that looks right in Settings.
|
|
333
|
+
*
|
|
334
|
+
* Both sides stamp `savedAt`, so the rule is simply "the newest write wins",
|
|
335
|
+
* with the host's own copy breaking a tie (it is encrypted). A missing stamp on
|
|
336
|
+
* either side loses to a present one; equal keys always resolve to the host's.
|
|
337
|
+
*
|
|
338
|
+
* @returns {{key:string, source:'host'|'shared'|'none', sharedAt:number, hostAt:number}}
|
|
339
|
+
*/
|
|
340
|
+
function preferNewest(storedKey, storedAt, o = {}) {
|
|
341
|
+
const ownKey = normalizeApiKey(storedKey);
|
|
342
|
+
const ownAt = Date.parse(storedAt || '') || 0;
|
|
343
|
+
const shared = readCredentials(o.dir);
|
|
344
|
+
const sharedKey = normalizeApiKey(shared.aegisApiKey);
|
|
345
|
+
const sharedAt = Date.parse(shared.savedAt || '') || 0;
|
|
346
|
+
|
|
347
|
+
if (!sharedKey) return { key: ownKey, source: ownKey ? 'host' : 'none', sharedAt, hostAt: ownAt };
|
|
348
|
+
if (!ownKey) return { key: sharedKey, source: 'shared', sharedAt, hostAt: ownAt };
|
|
349
|
+
if (ownKey === sharedKey) return { key: ownKey, source: 'host', sharedAt, hostAt: ownAt };
|
|
350
|
+
return sharedAt > ownAt
|
|
351
|
+
? { key: sharedKey, source: 'shared', sharedAt, hostAt: ownAt }
|
|
352
|
+
: { key: ownKey, source: 'host', sharedAt, hostAt: ownAt };
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** One line telling the user how to supply a key, used by every error path. */
|
|
356
|
+
const HOW_TO_SET = 'run `aegiscode login` (or /key inside a session) to save one';
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
KEY_ENV,
|
|
360
|
+
MEMORY_ENV,
|
|
361
|
+
HOME_ENV,
|
|
362
|
+
CREDENTIALS_FILE,
|
|
363
|
+
CONFIG_FILE,
|
|
364
|
+
aegisHome,
|
|
365
|
+
credentialsPath,
|
|
366
|
+
configPath,
|
|
367
|
+
normalizeApiKey,
|
|
368
|
+
validateApiKey,
|
|
369
|
+
readCredentials,
|
|
370
|
+
writeCredentials,
|
|
371
|
+
readLegacyConfig,
|
|
372
|
+
legacyKeyOnDisk,
|
|
373
|
+
adoptLegacy,
|
|
374
|
+
resolveApiKey,
|
|
375
|
+
hasApiKey,
|
|
376
|
+
saveApiKey,
|
|
377
|
+
clearApiKey,
|
|
378
|
+
resolveMemoryToken,
|
|
379
|
+
saveMemoryToken,
|
|
380
|
+
clearMemoryToken,
|
|
381
|
+
keyStatus,
|
|
382
|
+
clientOptions,
|
|
383
|
+
preferNewest,
|
|
384
|
+
sourceLabel,
|
|
385
|
+
HOW_TO_SET,
|
|
386
|
+
};
|
|
@@ -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
|
+
};
|