aegis-desktop 0.4.5 → 0.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.
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,
@@ -1,219 +1,93 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * sessions.js — local conversation persistence (plan P1 §5.1 / P3 §7).
5
- * Every session (any model class) persists to <dir>/sessions.json on each
6
- * message with a crash-safe temp-file rename. Pure Node + injectable dir,
7
- * so it unit-tests without Electron.
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 fs = require('node:fs');
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 JSON.parse(fs.readFileSync(sessionsFile(dir), 'utf8')) || {};
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
- * Normalize a remote `updated_at`/`updatedAt` to epoch milliseconds. The cloud
38
- * returns an ISO-8601 string (e.g. "2026-07-16T14:20:00"); the local store
39
- * uses `Date.now()` epoch-ms numbers. Comparing the two directly coerces the
40
- * string to NaN, which breaks last-write-wins ordering. ISO strings are
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
- return 0;
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
- * Merge remote conversation-sync records into the local store (pull half of
154
- * sync). Last-write-wins by `updatedAt`, but a local session with unsynced
155
- * edits (`pending`) always wins over the remote copy — it will overwrite the
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 mergeRemoteSessions(dir, remoteSessions) {
159
- const list = Array.isArray(remoteSessions) ? remoteSessions : [];
160
- const sessions = load(dir);
161
- let merged = 0;
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
- * Markdown export (session export, plan: Save as.../Export session). Each
188
- * message becomes a `## Role` heading followed by its content verbatim —
189
- * content is never re-escaped or re-wrapped, so any code fences a message
190
- * already contains (assistant replies routinely have them) survive untouched
191
- * instead of being nested inside an outer fence.
192
- */
193
- function toMarkdown(session) {
194
- const title = (session && (session.title || session.id)) || 'session';
195
- const messages = (session && Array.isArray(session.messages)) ? session.messages : [];
196
- const lines = [`# ${title}`, ''];
197
- for (const message of messages) {
198
- const role = (message && message.role) || 'unknown';
199
- const heading = role.charAt(0).toUpperCase() + role.slice(1);
200
- const content = (message && (message.content || message.text)) || '';
201
- lines.push(`## ${heading}`, '', content, '');
202
- }
203
- return lines.join('\n');
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
- /** JSON export: the session record as stored, pretty-printed. */
207
- function toJson(session) {
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
- const dir = dirOverride || resolveUserDataDir(app);
671
- const settings = createSettingsStore({ dir, safeStorage });
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
- return { engine, sessionsDir: dir, settings };
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
- const persistApiKey = (key) => settings.setAegisKey(key);
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
- const persistedKey = settings.aegisRawKey();
1569
- if (persistedKey) aegis.setApiKey(persistedKey);
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.5",
4
+ "version": "0.5.0",
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,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
+ };