aegiscode 6.3.2 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +77 -11
- package/bin/aegiscode.js +161 -2
- package/package.json +2 -2
- package/scripts/predist.mjs +5 -0
- package/src/app.js +178 -43
- package/src/cloudsync.js +401 -0
- package/src/commands.js +285 -17
- package/src/config.js +8 -6
- package/src/credentials.js +30 -0
- package/src/history.js +120 -8
- package/src/screens.js +159 -9
- package/src/secret.js +56 -0
- package/src/shared.js +45 -0
- package/vendor/client/credentials.js +386 -0
- package/vendor/client/session-store.js +418 -0
|
@@ -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
|
+
};
|