aegis-desktop 0.3.0 → 0.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.
@@ -39,6 +39,7 @@
39
39
  */
40
40
 
41
41
  const { spawn } = require('node:child_process');
42
+ const crypto = require('node:crypto');
42
43
  const fs = require('node:fs');
43
44
  const path = require('node:path');
44
45
  const { agentRoles } = require('./agents.js');
@@ -53,6 +54,11 @@ const GREP_SIZE_CAP = 10 * 1024 * 1024; // grep silently skips files above this
53
54
  const EXEC_TIMEOUT_DEFAULT = 120_000;
54
55
  const EXEC_TIMEOUT_CAP = 600_000; // 10 minutes, matches the CLI's cap
55
56
  const EXEC_MAX_BUFFER = 1_048_576; // 1 MB of combined stdout+stderr
57
+ // The LCS diff below is O(lines_before * lines_after); past this many cells
58
+ // (or this many lines on either side) the diff is skipped in favor of a
59
+ // one-line summary rather than freezing the approval flow on a huge file.
60
+ const DIFF_MAX_CELLS = 4_000_000;
61
+ const DIFF_MAX_LINES = 20_000;
56
62
 
57
63
  /** Truncate oversized tool output (the model never needs the whole log). */
58
64
  function cap(s) {
@@ -332,28 +338,260 @@ function writeFile({ file_path, content } = {}) {
332
338
  }
333
339
  }
334
340
 
335
- function editFile({ file_path, old_string, new_string, replace_all } = {}) {
341
+ function editFile(args = {}) {
342
+ try {
343
+ const preview = previewEditFile(args);
344
+ if (!preview.ok) return fail(preview.error);
345
+ fs.writeFileSync(args.file_path, preview.after, 'utf8');
346
+ return ok(`Edited ${args.file_path} (${preview.count} occurrence${preview.count > 1 ? 's' : ''} replaced)`);
347
+ } catch (e) {
348
+ return fail(e && e.message ? e.message : String(e));
349
+ }
350
+ }
351
+
352
+ // ── Approval-gate helpers ───────────────────────────────────────────────────
353
+ //
354
+ // The renderer approval gate (desktop/lib/local/engine.js gatedExecuteTool)
355
+ // needs to show the user a diff BEFORE a mutating call runs, then re-verify
356
+ // the file hasn't moved out from under it before actually writing. Everything
357
+ // below is pure preparation: it never writes to disk on its own except
358
+ // applyWriteChecked/applyEditChecked, which are the only functions engine.js
359
+ // calls once the user has approved.
360
+
361
+ /** exec/writeFile/editFile change machine state; listDir/glob/grep/readFile
362
+ * never do — this is the set the approval gate checks against. */
363
+ const MUTATING_TOOLS = new Set(['exec', 'writeFile', 'editFile']);
364
+
365
+ function sha256(text) {
366
+ return crypto.createHash('sha256').update(text == null ? '' : text, 'utf8').digest('hex');
367
+ }
368
+
369
+ /** Read a file for a preview/hash snapshot. Distinguishes "doesn't exist yet"
370
+ * (ok, exists:false) from a real read failure (permissions, is a directory —
371
+ * ok:false), so callers can tell a brand-new file from a broken path. */
372
+ function readForPreview(file_path) {
373
+ try {
374
+ if (!fs.existsSync(file_path)) return { ok: true, exists: false, content: null };
375
+ if (fs.statSync(file_path).isDirectory()) {
376
+ return { ok: false, error: `${file_path} is a directory` };
377
+ }
378
+ return { ok: true, exists: true, content: fs.readFileSync(file_path, 'utf8') };
379
+ } catch (e) {
380
+ return { ok: false, error: e && e.message ? e.message : String(e) };
381
+ }
382
+ }
383
+
384
+ /** Classic LCS line diff: returns the {type:'equal'|'delete'|'insert', a, b}
385
+ * op list turning array `a` into array `b`, indices into each array. */
386
+ function diffLines(a, b) {
387
+ const n = a.length;
388
+ const m = b.length;
389
+ const dp = new Array(n + 1);
390
+ for (let i = 0; i <= n; i++) dp[i] = new Int32Array(m + 1);
391
+ for (let i = n - 1; i >= 0; i--) {
392
+ for (let j = m - 1; j >= 0; j--) {
393
+ dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
394
+ }
395
+ }
396
+ const ops = [];
397
+ let i = 0;
398
+ let j = 0;
399
+ while (i < n && j < m) {
400
+ if (a[i] === b[j]) {
401
+ ops.push({ type: 'equal', a: i, b: j });
402
+ i++;
403
+ j++;
404
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
405
+ ops.push({ type: 'delete', a: i });
406
+ i++;
407
+ } else {
408
+ ops.push({ type: 'insert', b: j });
409
+ j++;
410
+ }
411
+ }
412
+ while (i < n) ops.push({ type: 'delete', a: i++ });
413
+ while (j < m) ops.push({ type: 'insert', b: j++ });
414
+ return ops;
415
+ }
416
+
417
+ /** Group an op list into unified-diff hunks (git diff -Ucontext style) and
418
+ * render them as text with a/b line numbers in the @@ headers. */
419
+ function formatUnifiedDiff(ops, a, b, { label, context }) {
420
+ const n = ops.length;
421
+ const keep = new Array(n).fill(false);
422
+ for (let i = 0; i < n; i++) {
423
+ if (ops[i].type === 'equal') continue;
424
+ keep[i] = true;
425
+ for (let k = 1; k <= context; k++) {
426
+ if (i - k >= 0) keep[i - k] = true;
427
+ if (i + k < n) keep[i + k] = true;
428
+ }
429
+ }
430
+ const hunkRanges = [];
431
+ let start = -1;
432
+ for (let i = 0; i <= n; i++) {
433
+ if (i < n && keep[i]) {
434
+ if (start === -1) start = i;
435
+ } else if (start !== -1) {
436
+ hunkRanges.push([start, i - 1]);
437
+ start = -1;
438
+ }
439
+ }
440
+ if (!hunkRanges.length) return `--- ${label}\n+++ ${label}\n(no changes)`;
441
+
442
+ const out = [`--- ${label}`, `+++ ${label}`];
443
+ for (const [s, e] of hunkRanges) {
444
+ let aLine = 1;
445
+ let bLine = 1;
446
+ for (let i = 0; i < s; i++) {
447
+ if (ops[i].type !== 'insert') aLine++;
448
+ if (ops[i].type !== 'delete') bLine++;
449
+ }
450
+ let aCount = 0;
451
+ let bCount = 0;
452
+ const body = [];
453
+ for (let i = s; i <= e; i++) {
454
+ const op = ops[i];
455
+ if (op.type === 'equal') {
456
+ body.push(` ${a[op.a]}`);
457
+ aCount++;
458
+ bCount++;
459
+ } else if (op.type === 'delete') {
460
+ body.push(`-${a[op.a]}`);
461
+ aCount++;
462
+ } else {
463
+ body.push(`+${b[op.b]}`);
464
+ bCount++;
465
+ }
466
+ }
467
+ out.push(`@@ -${aLine},${aCount} +${bLine},${bCount} @@`);
468
+ out.push(...body);
469
+ }
470
+ return out.join('\n');
471
+ }
472
+
473
+ /** Unified diff between two whole-file strings (`before` may be null — a new
474
+ * file). Falls back to a one-line summary for files too large to diff cheaply. */
475
+ function unifiedDiff(before, after, { label = 'file', context = 3 } = {}) {
476
+ const a = before == null ? [] : String(before).split('\n');
477
+ const b = after == null ? [] : String(after).split('\n');
478
+ if (a.length > DIFF_MAX_LINES || b.length > DIFF_MAX_LINES || a.length * b.length > DIFF_MAX_CELLS) {
479
+ const added = Math.max(0, b.length - a.length);
480
+ const removed = Math.max(0, a.length - b.length);
481
+ return `--- ${label}\n+++ ${label}\n@@ file too large to preview — approx +${added}/-${removed} lines @@`;
482
+ }
483
+ return formatUnifiedDiff(diffLines(a, b), a, b, { label, context });
484
+ }
485
+
486
+ /** Build the {before, after, diff, hash} preview for a writeFile call without
487
+ * touching disk. `hash` is the sha256 of the CURRENT on-disk content (null
488
+ * for a not-yet-existing file) — the snapshot applyWriteChecked re-verifies
489
+ * against before actually writing. */
490
+ function previewWriteFile({ file_path, content } = {}) {
491
+ if (!file_path) return { ok: false, error: 'file_path is required' };
492
+ const read = readForPreview(file_path);
493
+ if (!read.ok) return { ok: false, error: read.error };
494
+ const before = read.exists ? read.content : null;
495
+ const after = String(content == null ? '' : content);
496
+ return {
497
+ ok: true,
498
+ before,
499
+ after,
500
+ diff: unifiedDiff(before, after, { label: file_path }),
501
+ hash: before == null ? null : sha256(before),
502
+ };
503
+ }
504
+
505
+ /** Same shape as previewWriteFile, for editFile — shares its validation with
506
+ * the executor above so there is exactly one place that knows how to apply
507
+ * an edit. */
508
+ function previewEditFile({ file_path, old_string, new_string, replace_all } = {}) {
509
+ if (!file_path) return { ok: false, error: 'file_path is required' };
510
+ if (old_string === undefined || old_string === '') {
511
+ return { ok: false, error: 'old_string is required and must be non-empty' };
512
+ }
513
+ const read = readForPreview(file_path);
514
+ if (!read.ok) return { ok: false, error: read.error };
515
+ if (!read.exists) return { ok: false, error: `${file_path} does not exist` };
516
+ const before = read.content;
517
+ const count = before.split(old_string).length - 1;
518
+ if (count === 0) return { ok: false, error: `old_string not found in ${file_path}` };
519
+ if (count > 1 && !replace_all) {
520
+ return { ok: false, error: `old_string is not unique (${count} matches) — use replace_all or more context` };
521
+ }
522
+ const after = replace_all
523
+ ? before.split(old_string).join(new_string == null ? '' : new_string)
524
+ : before.replace(old_string, new_string == null ? '' : new_string);
525
+ return {
526
+ ok: true,
527
+ before,
528
+ after,
529
+ diff: unifiedDiff(before, after, { label: file_path }),
530
+ hash: sha256(before),
531
+ count,
532
+ };
533
+ }
534
+
535
+ /** Dispatch a preview by tool name. Only writeFile/editFile have one — exec
536
+ * has nothing to diff, and the approval gate skips this call for it. */
537
+ function previewMutation(name, args) {
538
+ if (name === 'writeFile') return previewWriteFile(args);
539
+ if (name === 'editFile') return previewEditFile(args);
540
+ return { ok: false, error: `no diff preview for ${name}` };
541
+ }
542
+
543
+ /** Apply a writeFile the user has approved, but only if the file on disk
544
+ * still matches the hash captured at preview time — otherwise something
545
+ * else changed it while the approval card was open, and applying blind
546
+ * would silently clobber that change. */
547
+ function applyWriteChecked({ file_path, content } = {}, expectedHash) {
336
548
  try {
337
549
  if (!file_path) return fail('file_path is required');
338
- if (old_string === undefined || old_string === '') {
339
- return fail('old_string is required and must be non-empty');
550
+ const read = readForPreview(file_path);
551
+ if (!read.ok) return fail(read.error);
552
+ const currentHash = read.exists ? sha256(read.content) : null;
553
+ if (currentHash !== expectedHash) {
554
+ return fail(
555
+ `${file_path} changed on disk since the diff was shown — refusing to apply a stale write. Re-run writeFile to get an updated diff.`
556
+ );
340
557
  }
341
- const src = fs.readFileSync(file_path, 'utf8');
342
- const count = src.split(old_string).length - 1;
343
- if (count === 0) return fail(`old_string not found in ${file_path}`);
344
- if (count > 1 && !replace_all) {
345
- return fail(`old_string is not unique (${count} matches) — use replace_all or more context`);
558
+ fs.mkdirSync(path.dirname(file_path), { recursive: true });
559
+ fs.writeFileSync(file_path, String(content == null ? '' : content), 'utf8');
560
+ return ok(`Wrote ${String(content == null ? '' : content).length} bytes to ${file_path}`);
561
+ } catch (e) {
562
+ return fail(e && e.message ? e.message : String(e));
563
+ }
564
+ }
565
+
566
+ /** Same guard as applyWriteChecked, for an already-computed editFile result
567
+ * (`after` — the preview's replacement, not recomputed here since a hash
568
+ * match means the source it was computed from is still exactly on disk). */
569
+ function applyEditChecked({ file_path, after } = {}, expectedHash) {
570
+ try {
571
+ if (!file_path) return fail('file_path is required');
572
+ const read = readForPreview(file_path);
573
+ if (!read.ok) return fail(read.error);
574
+ const currentHash = read.exists ? sha256(read.content) : null;
575
+ if (currentHash !== expectedHash) {
576
+ return fail(
577
+ `${file_path} changed on disk since the diff was shown — refusing to apply a stale edit. Re-run editFile to get an updated diff.`
578
+ );
346
579
  }
347
- const next = replace_all
348
- ? src.split(old_string).join(new_string == null ? '' : new_string)
349
- : src.replace(old_string, new_string == null ? '' : new_string);
350
- fs.writeFileSync(file_path, next, 'utf8');
351
- return ok(`Edited ${file_path} (${count} occurrence${count > 1 ? 's' : ''} replaced)`);
580
+ fs.writeFileSync(file_path, after, 'utf8');
581
+ return ok(`Edited ${file_path}`);
352
582
  } catch (e) {
353
583
  return fail(e && e.message ? e.message : String(e));
354
584
  }
355
585
  }
356
586
 
587
+ /** Apply an approved writeFile/editFile call using the hash captured in its
588
+ * `preview` (see previewMutation) — the single entry point engine.js calls
589
+ * once the user has said yes. */
590
+ function applyChecked(name, args, preview) {
591
+ if (name === 'writeFile') return applyWriteChecked(args, preview.hash);
592
+ return applyEditChecked({ file_path: args.file_path, after: preview.after }, preview.hash);
593
+ }
594
+
357
595
  const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', '.aegiscode']);
358
596
 
359
597
  function listDir({ path: dir } = {}) {
@@ -626,6 +864,10 @@ module.exports = {
626
864
  executeTool,
627
865
  isTool,
628
866
  toolResultText,
867
+ // approval gate (desktop/lib/local/engine.js gatedExecuteTool)
868
+ MUTATING_TOOLS,
869
+ previewMutation,
870
+ applyChecked,
629
871
  // limits (unit tests assert against them instead of hard-coding numbers)
630
872
  OUTPUT_CAP,
631
873
  READ_LINE_CAP,
@@ -635,4 +877,6 @@ module.exports = {
635
877
  EXEC_TIMEOUT_DEFAULT,
636
878
  EXEC_TIMEOUT_CAP,
637
879
  EXEC_MAX_BUFFER,
880
+ DIFF_MAX_CELLS,
881
+ DIFF_MAX_LINES,
638
882
  };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * quick-launcher.js — pure geometry + gating logic for the global quick-
5
+ * launcher popup (native desktop plumbing). Mirrors desktop/lib/window-state.js:
6
+ * no Electron import, so both functions unit-test without the Electron binary.
7
+ * main.js wires the result into a BrowserWindow + globalShortcut; this module
8
+ * only decides where the window should sit and whether the shortcut should be
9
+ * registered at all.
10
+ */
11
+
12
+ /**
13
+ * A global shortcut is a machine-wide hook — every packaged build ships it,
14
+ * but a `npm start` dev run must NOT silently start grabbing a hotkey that
15
+ * might collide with whatever else the developer has running, unless they've
16
+ * explicitly opted in via the settings flag.
17
+ */
18
+ function shouldEnableGlobalShortcut({ isPackaged, enabled } = {}) {
19
+ return Boolean(isPackaged) || Boolean(enabled);
20
+ }
21
+
22
+ /**
23
+ * Where the launcher window should land: centred on the cursor's current
24
+ * position, clamped so the whole window stays within that cursor's display
25
+ * work area (never straddling into an unplugged second monitor, never
26
+ * hanging off a screen edge). Falls back to centring on the first available
27
+ * display — or the given fallback rectangle — when there is no usable cursor
28
+ * point or display list (e.g. a headless test).
29
+ *
30
+ * `cursor` / `displays` take the plain shapes electron.screen already
31
+ * returns (`{x,y}` / `{workArea:{x,y,width,height}}`), so this stays pure and
32
+ * Electron-free for testing, exactly like window-state.js's clampToDisplay.
33
+ */
34
+ function computeQuickLauncherBounds({ cursor, displays, size, fallback } = {}) {
35
+ const width = Math.max(1, (size && size.width) || 560);
36
+ const height = Math.max(1, (size && size.height) || 320);
37
+ const list = Array.isArray(displays) ? displays : [];
38
+ const point =
39
+ cursor && Number.isFinite(cursor.x) && Number.isFinite(cursor.y) ? cursor : null;
40
+
41
+ const containing = point
42
+ ? list.find((d) => {
43
+ const wa = d && d.workArea;
44
+ return (
45
+ wa &&
46
+ point.x >= wa.x &&
47
+ point.x < wa.x + wa.width &&
48
+ point.y >= wa.y &&
49
+ point.y < wa.y + wa.height
50
+ );
51
+ })
52
+ : null;
53
+
54
+ const target = containing || list[0] || null;
55
+ if (!target || !target.workArea) {
56
+ return {
57
+ x: (fallback && fallback.x) || 0,
58
+ y: (fallback && fallback.y) || 0,
59
+ width,
60
+ height,
61
+ };
62
+ }
63
+
64
+ const wa = target.workArea;
65
+ let x;
66
+ let y;
67
+ if (point) {
68
+ x = Math.round(point.x - width / 2);
69
+ y = Math.round(point.y - height / 2);
70
+ } else {
71
+ x = Math.round(wa.x + (wa.width - width) / 2);
72
+ y = Math.round(wa.y + (wa.height - height) / 2);
73
+ }
74
+
75
+ x = Math.min(Math.max(x, wa.x), Math.max(wa.x, wa.x + wa.width - width));
76
+ y = Math.min(Math.max(y, wa.y), Math.max(wa.y, wa.y + wa.height - height));
77
+
78
+ return { x, y, width, height };
79
+ }
80
+
81
+ module.exports = { shouldEnableGlobalShortcut, computeQuickLauncherBounds };
package/lib/settings.js CHANGED
@@ -35,10 +35,31 @@ const AEGIS_KEY_NAMESPACE = '__aegis';
35
35
  * migrateLegacyAegisKey() relocates it (and list() hides it meanwhile). */
36
36
  const LEGACY_AEGIS_NAMESPACE = 'aegis';
37
37
 
38
+ /** Reserved namespace for the global quick-launcher shortcut config
39
+ * ({ enabled, shortcut }) — app-level settings, not a provider, so it must
40
+ * stay out of the provider CRUD surface for the same reason the AEGIS key
41
+ * does (see AEGIS_KEY_NAMESPACE above). */
42
+ const QUICK_LAUNCHER_NAMESPACE = '__quickLauncher';
43
+
44
+ /** Default global shortcut: rare enough on both major platforms to avoid
45
+ * fighting existing app/OS bindings, memorable enough to type once. */
46
+ const DEFAULT_QUICK_LAUNCHER_SHORTCUT = 'CmdOrCtrl+Shift+Space';
47
+
48
+ /** Reserved namespace for the tool-call approval ("confirm mode") preference:
49
+ * `{ enabled }` — app-level, not a provider, so it stays out of the provider
50
+ * CRUD surface for the same reason the AEGIS key does. Default is ON
51
+ * (settings.getConfirmMode() === true when unset), which is the historical
52
+ * behaviour: exec/writeFile/editFile always asked for approval.
53
+ * NOTE: like the AEGIS key and the quick launcher, this lives in its own
54
+ * top-level namespace — never inside a provider's `cfg[provider]` object. */
55
+ const CONFIRM_MODE_NAMESPACE = '__confirmMode';
56
+
38
57
  /** Namespaces the provider-config surface must never see or mutate. */
39
58
  const RESERVED_NAMESPACES = Object.freeze([
40
59
  AEGIS_KEY_NAMESPACE,
41
60
  LEGACY_AEGIS_NAMESPACE,
61
+ QUICK_LAUNCHER_NAMESPACE,
62
+ CONFIRM_MODE_NAMESPACE,
42
63
  ]);
43
64
 
44
65
  /** True for the AEGIS-key namespace(s) — provider CRUD must refuse these. */
@@ -199,6 +220,54 @@ function createSettingsStore({ dir, safeStorage } = {}) {
199
220
  }
200
221
  }
201
222
 
223
+ // --- Quick launcher: reserved namespace, plain (unencrypted) config ------
224
+ // No secret lives here — just a bool and a shortcut string — so unlike the
225
+ // AEGIS key above there is nothing to encrypt/decrypt.
226
+
227
+ function quickLauncherConfig() {
228
+ const cfg = load()[QUICK_LAUNCHER_NAMESPACE] || {};
229
+ const shortcut =
230
+ typeof cfg.shortcut === 'string' && cfg.shortcut.trim()
231
+ ? cfg.shortcut.trim()
232
+ : DEFAULT_QUICK_LAUNCHER_SHORTCUT;
233
+ return { enabled: Boolean(cfg.enabled), shortcut };
234
+ }
235
+
236
+ function setQuickLauncherConfig({ enabled, shortcut } = {}) {
237
+ const data = load();
238
+ data[QUICK_LAUNCHER_NAMESPACE] = {
239
+ enabled: Boolean(enabled),
240
+ shortcut:
241
+ typeof shortcut === 'string' && shortcut.trim()
242
+ ? shortcut.trim()
243
+ : DEFAULT_QUICK_LAUNCHER_SHORTCUT,
244
+ };
245
+ save(data);
246
+ return quickLauncherConfig();
247
+ }
248
+
249
+ // --- Confirm mode: reserved namespace, plain (unencrypted) preference ----
250
+ // "Confirm before running tools" — the ON/OFF switch for the renderer's
251
+ // tool-call approval gate (desktop/lib/local/engine.js gatedExecuteTool).
252
+ // ON (default, and the behaviour shipped so far) means every mutating tool
253
+ // call — exec / writeFile / editFile — is previewed and approved by the user
254
+ // first. OFF means the engine runs them straight through, like a tool the
255
+ // session already allowed. Nothing secret is stored here, so no encryption.
256
+
257
+ function getConfirmMode() {
258
+ const cfg = load()[CONFIRM_MODE_NAMESPACE] || {};
259
+ // Unset === true: an existing install that never touched the toggle keeps
260
+ // the gate exactly as it was.
261
+ return cfg.enabled === undefined ? true : Boolean(cfg.enabled);
262
+ }
263
+
264
+ function setConfirmMode(enabled) {
265
+ const data = load();
266
+ data[CONFIRM_MODE_NAMESPACE] = { enabled: Boolean(enabled) };
267
+ save(data);
268
+ return getConfirmMode();
269
+ }
270
+
202
271
  return {
203
272
  file,
204
273
  get,
@@ -211,6 +280,10 @@ function createSettingsStore({ dir, safeStorage } = {}) {
211
280
  aegisRawKey,
212
281
  setAegisKey,
213
282
  migrateLegacyAegisKey,
283
+ quickLauncherConfig,
284
+ setQuickLauncherConfig,
285
+ getConfirmMode,
286
+ setConfirmMode,
214
287
  };
215
288
  }
216
289
 
@@ -218,6 +291,9 @@ module.exports = {
218
291
  SETTINGS_FILE,
219
292
  AEGIS_KEY_NAMESPACE,
220
293
  LEGACY_AEGIS_NAMESPACE,
294
+ QUICK_LAUNCHER_NAMESPACE,
295
+ CONFIRM_MODE_NAMESPACE,
296
+ DEFAULT_QUICK_LAUNCHER_SHORTCUT,
221
297
  RESERVED_NAMESPACES,
222
298
  isReservedNamespace,
223
299
  maskKey,
@@ -183,6 +183,31 @@ function mergeRemoteSessions(dir, remoteSessions) {
183
183
  return merged;
184
184
  }
185
185
 
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
+ }
205
+
206
+ /** JSON export: the session record as stored, pretty-printed. */
207
+ function toJson(session) {
208
+ return JSON.stringify(session, null, 2);
209
+ }
210
+
186
211
  module.exports = {
187
212
  sessionsFile,
188
213
  load,
@@ -196,4 +221,6 @@ module.exports = {
196
221
  markPending,
197
222
  listPending,
198
223
  mergeRemoteSessions,
224
+ toMarkdown,
225
+ toJson,
199
226
  };
@@ -0,0 +1,63 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * window-state.js — persisted BrowserWindow bounds (native desktop plumbing).
5
+ * Pure Node + injectable dir, mirrors desktop/lib/sync/memory-queue.js: reads
6
+ * and writes <dir>/window-state.json, no Electron import, so it unit-tests
7
+ * without the Electron binary.
8
+ */
9
+
10
+ const fs = require('node:fs');
11
+ const path = require('node:path');
12
+
13
+ function stateFile(dir) {
14
+ return path.join(dir, 'window-state.json');
15
+ }
16
+
17
+ function load(dir) {
18
+ try {
19
+ const data = JSON.parse(fs.readFileSync(stateFile(dir), 'utf8'));
20
+ return data && typeof data === 'object' ? data : null;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function save(dir, state) {
27
+ const file = stateFile(dir);
28
+ fs.mkdirSync(path.dirname(file), { recursive: true });
29
+ const tmp = `${file}.tmp-${process.pid}`;
30
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
31
+ fs.renameSync(tmp, file);
32
+ }
33
+
34
+ /**
35
+ * Clamp a saved rectangle so it always intersects at least one connected
36
+ * display's work area. A saved position from a monitor that has since been
37
+ * unplugged, or from a resolution that shrank, would otherwise place the
38
+ * window off every visible screen — it "opens" but is unreachable. `displays`
39
+ * takes plain `{ workArea: {x,y,width,height} }` objects (electron.screen's
40
+ * shape) so this stays pure and Electron-free for testing.
41
+ */
42
+ function clampToDisplay(bounds, displays, fallback) {
43
+ if (!bounds || typeof bounds !== 'object') return { ...fallback };
44
+ const { x, y, width, height } = bounds;
45
+ if (![x, y, width, height].every((n) => Number.isFinite(n))) return { ...fallback };
46
+
47
+ const w = Math.max(1, width);
48
+ const h = Math.max(1, height);
49
+ const list = Array.isArray(displays) ? displays : [];
50
+
51
+ const visible = list.some((d) => {
52
+ const wa = d && d.workArea;
53
+ if (!wa) return false;
54
+ return x < wa.x + wa.width && x + w > wa.x && y < wa.y + wa.height && y + h > wa.y;
55
+ });
56
+
57
+ if (visible) return { x, y, width: w, height: h };
58
+ // Position is off every screen: keep the saved size but drop x/y so the
59
+ // caller's default centering (no x/y passed to BrowserWindow) takes over.
60
+ return { ...fallback, width: w, height: h };
61
+ }
62
+
63
+ module.exports = { stateFile, load, save, clampToDisplay };