aegiscode 6.3.1 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,401 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Cloud conversation sync for the terminal host.
5
+ *
6
+ * The desktop has had this since P4.5 (`desktop/lib/sync/sessions.js` +
7
+ * `desktop/main.js`'s sync handlers, behind the "Sync now" button). The CLI had
8
+ * the transport for it — `client.conversationSyncPush/Pull` are the same two
9
+ * shared-client methods the desktop calls — and no code that used them, plus a
10
+ * `/cloud` command whose reply was "managed by the aegis CLI: run `aegis login`
11
+ * — aegiscode does not store cloud keys." That was a dead end twice over: the
12
+ * key *is* storable now (credentials.js), and nothing else was going to push
13
+ * this host's sessions anywhere.
14
+ *
15
+ * Shape of the local store: the sessions that sync are the ones the CLI
16
+ * already persists in `history.jsonl` (one record per exchange, keyed by
17
+ * sessionId). Building a second session store for sync would mean `/resume`
18
+ * and cloud sync disagreed about which sessions exist, so there is exactly one
19
+ * and this module reads it, then writes imported remote sessions back into it —
20
+ * which is what makes a pulled session show up in `/resume` like any other.
21
+ *
22
+ * What is tracked locally, in `sync-state.json`:
23
+ *
24
+ * sessions[id] = { syncedAt, localUpdatedAt, remoteUpdatedAt, importedRemoteAt, lastError }
25
+ *
26
+ * "Pending" is derived, never stored as a flag that can go stale: a session is
27
+ * pending when its newest local record is newer than the last successful push.
28
+ *
29
+ * Quota note, because it is the difference between a working sync and a
30
+ * surprise: the server charges a push for the *growth* of a session
31
+ * (`_session_token_delta` in aegis1's conversation_sync), refuses with 402 once
32
+ * the plan's synced-token ceiling would be exceeded, and always serves pulls.
33
+ * So sync is opt-in (config `cloudSync`, default off) rather than automatic on
34
+ * every turn, and a 402 is reported as the quota error it is instead of being
35
+ * swallowed into "sync failed".
36
+ */
37
+
38
+ const fs = require('node:fs');
39
+ const path = require('node:path');
40
+ const { aegisDir } = require('./config.js');
41
+ const {
42
+ historyPath,
43
+ appendHistoryEntries,
44
+ readHistoryEntries,
45
+ readSessionTranscript,
46
+ } = require('./history.js');
47
+ const { estimateTokens } = require('./tokens.js');
48
+
49
+ /** Recorded on every session this host pushes, so the server can tell hosts apart. */
50
+ const SOURCE = 'aegiscode-cli';
51
+ const SYNC_FILE = 'sync-state.json';
52
+ const DEFAULT_LIMIT = 50;
53
+
54
+ function syncStatePath() {
55
+ return path.join(aegisDir(), SYNC_FILE);
56
+ }
57
+
58
+ /** The sync ledger, or an empty one. Never throws on a missing/corrupt file. */
59
+ function loadState() {
60
+ try {
61
+ const parsed = JSON.parse(fs.readFileSync(syncStatePath(), 'utf8'));
62
+ if (parsed && typeof parsed === 'object') {
63
+ return {
64
+ sessions: parsed.sessions && typeof parsed.sessions === 'object' ? parsed.sessions : {},
65
+ lastPushAt: parsed.lastPushAt || null,
66
+ lastPullAt: parsed.lastPullAt || null,
67
+ importedRemoteAt: parsed.importedRemoteAt || {},
68
+ // How many messages of each remote session are already in the local
69
+ // store. Without this a session that grew by one turn re-imported its
70
+ // whole transcript, so every pull duplicated every earlier exchange and
71
+ // /cost doubled with each sync.
72
+ importedRemoteCount: parsed.importedRemoteCount || {},
73
+ };
74
+ }
75
+ } catch {}
76
+ return { sessions: {}, lastPushAt: null, lastPullAt: null, importedRemoteAt: {}, importedRemoteCount: {} };
77
+ }
78
+
79
+ function saveState(state) {
80
+ try {
81
+ fs.mkdirSync(aegisDir(), { recursive: true });
82
+ const tmp = syncStatePath() + '.tmp';
83
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + '\n');
84
+ fs.renameSync(tmp, syncStatePath());
85
+ return true;
86
+ } catch (e) {
87
+ if (process.env.AEGIS_HIST_DEBUG) console.error('[cloudsync] write failed:', e);
88
+ return false;
89
+ }
90
+ }
91
+
92
+ /** Local sessions from history.jsonl, newest activity first. One pass. */
93
+ function localSessions({ limit = DEFAULT_LIMIT } = {}) {
94
+ const byId = new Map();
95
+ for (const e of readHistoryEntries()) {
96
+ if (!e || !e.sessionId) continue;
97
+ const cur = byId.get(e.sessionId) || {
98
+ id: e.sessionId,
99
+ cwd: e.cwd || '',
100
+ firstPrompt: '',
101
+ updatedAt: '',
102
+ exchanges: 0,
103
+ };
104
+ cur.exchanges += 1;
105
+ if (!cur.firstPrompt && e.prompt) cur.firstPrompt = String(e.prompt);
106
+ if (String(e.ts || '') > String(cur.updatedAt || '')) cur.updatedAt = e.ts || '';
107
+ byId.set(e.sessionId, cur);
108
+ }
109
+ return [...byId.values()]
110
+ .sort((a, b) => (String(a.updatedAt) < String(b.updatedAt) ? 1 : -1))
111
+ .slice(0, Math.max(1, limit));
112
+ }
113
+
114
+ /** Title for a session: its first prompt, collapsed to one line. */
115
+ function titleFor(session) {
116
+ const raw = String((session && session.firstPrompt) || '').replace(/\s+/g, ' ').trim();
117
+ return raw.slice(0, 80);
118
+ }
119
+
120
+ /**
121
+ * A pulled/pushed transcript in the wire shape.
122
+ *
123
+ * `messages` is `{role, content}` — the same keys the desktop pushes, because
124
+ * the server stores this array verbatim and a different spelling here would
125
+ * make one host's sessions unreadable to the other.
126
+ */
127
+ function buildTranscript(session, o = {}) {
128
+ const rows = o.transcript || readSessionTranscript(session.id);
129
+ const messages = rows
130
+ .filter((r) => r && (r.role === 'user' || r.role === 'assistant'))
131
+ .map((r) => ({ role: r.role, content: String(r.text == null ? '' : r.text) }));
132
+ return {
133
+ session_id: session.id,
134
+ title: o.title != null ? o.title : titleFor(session),
135
+ messages,
136
+ source: o.source || SOURCE,
137
+ };
138
+ }
139
+
140
+ function isPending(session, state) {
141
+ const rec = state.sessions[session.id];
142
+ if (!rec || !rec.syncedAt) return true;
143
+ return String(session.updatedAt || '') > String(rec.localUpdatedAt || '');
144
+ }
145
+
146
+ /** Sessions with local changes the cloud has not seen, oldest first. */
147
+ function listPending(o = {}) {
148
+ const state = o.state || loadState();
149
+ return localSessions(o)
150
+ .filter((s) => isPending(s, state))
151
+ .reverse();
152
+ }
153
+
154
+ function markSynced(state, id, o = {}) {
155
+ const prev = state.sessions[id] || {};
156
+ state.sessions[id] = {
157
+ ...prev,
158
+ syncedAt: Date.now(),
159
+ localUpdatedAt: o.localUpdatedAt || prev.localUpdatedAt || '',
160
+ remoteUpdatedAt: o.remoteUpdatedAt || prev.remoteUpdatedAt || null,
161
+ lastError: null,
162
+ };
163
+ return state.sessions[id];
164
+ }
165
+
166
+ function markFailed(state, id, err) {
167
+ const prev = state.sessions[id] || {};
168
+ state.sessions[id] = { ...prev, lastError: err, failedAt: Date.now() };
169
+ return state.sessions[id];
170
+ }
171
+
172
+ /** Flatten a remote messages array into history records for the local store. */
173
+ function entriesFromRemote(session, now, skip = 0) {
174
+ const out = [];
175
+ const all = Array.isArray(session.messages) ? session.messages : [];
176
+ // Only the messages this host has not already written: a resync after a new
177
+ // turn must add that turn, not the whole thread again.
178
+ const messages = skip > 0 && skip <= all.length ? all.slice(skip) : all;
179
+ let pendingPrompt = null;
180
+ const flush = (prompt, reply) => {
181
+ if (prompt == null && reply == null) return;
182
+ out.push({
183
+ ts: session.updated_at || now,
184
+ sessionId: session.session_id,
185
+ cwd: '',
186
+ prompt: prompt == null ? '' : String(prompt),
187
+ reply: reply == null ? '' : String(reply),
188
+ status: 'done',
189
+ imported: true,
190
+ importedFrom: String(session.source || 'cloud'),
191
+ tokens: {
192
+ input: estimateTokens(String(prompt || '')),
193
+ output: estimateTokens(String(reply || '')),
194
+ cacheRead: 0,
195
+ cacheWrite: 0,
196
+ real: false,
197
+ },
198
+ });
199
+ };
200
+ for (const m of messages) {
201
+ const role = m && m.role;
202
+ const text = m && (m.content != null ? m.content : m.text);
203
+ if (role === 'user') {
204
+ if (pendingPrompt != null) flush(pendingPrompt, null);
205
+ pendingPrompt = text == null ? '' : text;
206
+ } else if (role === 'assistant' || role === 'system') {
207
+ flush(pendingPrompt, text);
208
+ pendingPrompt = null;
209
+ }
210
+ }
211
+ if (pendingPrompt != null) flush(pendingPrompt, null);
212
+ return out;
213
+ }
214
+
215
+ /** A quota refusal is not a transport failure — say which one it is. */
216
+ function describeError(err) {
217
+ const status = err && (err.status || (err.response && err.response.status));
218
+ const message = (err && err.message) || String(err);
219
+ if (status === 402) {
220
+ return { kind: 'quota', status, message, hint: 'the plan’s synced-token ceiling is reached — /cloud sync off, or free space in the account dashboard' };
221
+ }
222
+ if (status === 401) {
223
+ return { kind: 'auth', status, message, hint: 'the memory token was refused — run /key <api_key> to re-exchange it' };
224
+ }
225
+ return { kind: 'error', status: status || null, message, hint: null };
226
+ }
227
+
228
+ /**
229
+ * Push every pending session, one request each.
230
+ *
231
+ * A single failure does not abort the run: the sessions that did go out are
232
+ * recorded as synced, and the ones that did not keep their `lastError` so the
233
+ * next attempt (or `/cloud status`) reports the real reason rather than a
234
+ * session that silently looks in sync.
235
+ */
236
+ async function push(client, o = {}) {
237
+ const state = o.state || loadState();
238
+ const limit = o.limit || DEFAULT_LIMIT;
239
+ const pending = o.sessionIds
240
+ ? o.sessionIds.map((id) => localSessions({ limit: Number.MAX_SAFE_INTEGER, state }).find((s) => s.id === id)).filter(Boolean)
241
+ : listPending({ ...o, limit, state });
242
+
243
+ const pushed = [];
244
+ const failed = [];
245
+ const skipped = [];
246
+ for (const session of pending) {
247
+ const transcript = buildTranscript(session, {});
248
+ if (!transcript.messages.length) {
249
+ skipped.push({ id: session.id, reason: 'no messages' });
250
+ continue;
251
+ }
252
+ try {
253
+ const res = await client.conversationSyncPush(transcript);
254
+ const remoteId = (res && (res.session_id || res.id)) || session.id;
255
+ markSynced(state, session.id, {
256
+ localUpdatedAt: session.updatedAt,
257
+ remoteUpdatedAt: (res && res.updated_at) || new Date().toISOString(),
258
+ });
259
+ pushed.push({
260
+ id: session.id,
261
+ remoteId,
262
+ messages: transcript.messages.length,
263
+ title: transcript.title,
264
+ quota: res && res.token_limit ? { used: res.tokens_used, limit: res.token_limit } : null,
265
+ });
266
+ if (typeof o.onProgress === 'function') o.onProgress({ phase: 'push', id: session.id });
267
+ } catch (err) {
268
+ const info = describeError(err);
269
+ markFailed(state, session.id, info.message);
270
+ failed.push({ id: session.id, ...info });
271
+ if (info.kind === 'quota' || info.kind === 'auth') break; // every later one fails the same way
272
+ }
273
+ }
274
+ if (pushed.length) state.lastPushAt = Date.now();
275
+ saveState(state);
276
+ return { pushed, failed, skipped, pending: listPending({ state, limit }).length };
277
+ }
278
+
279
+ /**
280
+ * Pull the account's remote sessions.
281
+ *
282
+ * `import` writes remote transcripts into history.jsonl so `/resume` sees them.
283
+ * Re-pulling an unchanged session imports nothing — otherwise every pull would
284
+ * duplicate the whole account into the local store, and `/cost` would double
285
+ * with each sync.
286
+ */
287
+ async function pull(client, o = {}) {
288
+ const state = o.state || loadState();
289
+ const importRemote = o.import !== false;
290
+ let data;
291
+ try {
292
+ data = await client.conversationSyncPull();
293
+ } catch (err) {
294
+ return { ok: false, sessions: [], imported: 0, failed: [describeError(err)] };
295
+ }
296
+ const sessions = (data && data.sessions) || [];
297
+ const imported = [];
298
+ const now = new Date().toISOString();
299
+ const batch = [];
300
+
301
+ for (const remote of sessions) {
302
+ const id = remote && remote.session_id;
303
+ if (!id) continue;
304
+ const seenAt = state.importedRemoteAt[id];
305
+ const total = Array.isArray(remote.messages) ? remote.messages.length : 0;
306
+ const seenCount = Number((state.importedRemoteCount || {})[id] || 0);
307
+ const changed = !seenAt || seenAt !== remote.updated_at || total !== seenCount;
308
+ if (importRemote && changed) {
309
+ const entries = entriesFromRemote(remote, now, total >= seenCount ? seenCount : 0);
310
+ if (entries.length) batch.push(...entries);
311
+ state.importedRemoteAt[id] = remote.updated_at || now;
312
+ state.importedRemoteCount = { ...(state.importedRemoteCount || {}), [id]: total };
313
+ }
314
+ // A session that came back from the cloud is in sync by definition; without
315
+ // this the very next `push` would send it straight back up.
316
+ markSynced(state, id, {
317
+ localUpdatedAt: remote.updated_at || now,
318
+ remoteUpdatedAt: remote.updated_at || now,
319
+ });
320
+ imported.push({
321
+ id,
322
+ title: remote.title || '',
323
+ messages: (remote.messages || []).length,
324
+ source: remote.source || '',
325
+ updatedAt: remote.updated_at || null,
326
+ imported: importRemote && changed,
327
+ });
328
+ }
329
+
330
+ const written = batch.length ? appendHistoryEntries(batch) : 0;
331
+ state.lastPullAt = Date.now();
332
+ saveState(state);
333
+ return {
334
+ ok: true,
335
+ sessions: imported,
336
+ remote: sessions.length,
337
+ imported: written,
338
+ failed: [],
339
+ };
340
+ }
341
+
342
+ /** Push then pull. Either half can fail without hiding the other's result. */
343
+ async function syncNow(client, o = {}) {
344
+ const state = o.state || loadState();
345
+ const out = { ok: true, push: null, pull: null };
346
+ try {
347
+ out.push = await push(client, { ...o, state });
348
+ } catch (err) {
349
+ out.ok = false;
350
+ out.push = { pushed: [], failed: [describeError(err)], skipped: [] };
351
+ }
352
+ try {
353
+ out.pull = await pull(client, { ...o, state });
354
+ if (out.pull && out.pull.ok === false) out.ok = false;
355
+ } catch (err) {
356
+ out.ok = false;
357
+ out.pull = { ok: false, sessions: [], imported: 0, failed: [describeError(err)] };
358
+ }
359
+ return out;
360
+ }
361
+
362
+ /** Counts for a status panel, with no network access. */
363
+ function status() {
364
+ const state = loadState();
365
+ const sessions = localSessions({ limit: Number.MAX_SAFE_INTEGER });
366
+ const pending = sessions.filter((s) => isPending(s, state));
367
+ const errors = sessions
368
+ .filter((s) => state.sessions[s.id] && state.sessions[s.id].lastError)
369
+ .map((s) => ({ id: s.id, error: state.sessions[s.id].lastError }));
370
+ return {
371
+ local: sessions.length,
372
+ pending: pending.length,
373
+ synced: sessions.length - pending.length,
374
+ lastPushAt: state.lastPushAt,
375
+ lastPullAt: state.lastPullAt,
376
+ importedRemote: Object.keys(state.importedRemoteAt || {}).length,
377
+ errors,
378
+ path: syncStatePath(),
379
+ historyPath: historyPath(),
380
+ };
381
+ }
382
+
383
+ module.exports = {
384
+ SOURCE,
385
+ syncStatePath,
386
+ loadState,
387
+ saveState,
388
+ localSessions,
389
+ titleFor,
390
+ buildTranscript,
391
+ isPending,
392
+ listPending,
393
+ markSynced,
394
+ markFailed,
395
+ entriesFromRemote,
396
+ describeError,
397
+ push,
398
+ pull,
399
+ syncNow,
400
+ status,
401
+ };