aegis-desktop 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/main.js +54 -16
  2. package/package.json +1 -1
  3. package/renderer/app.js +57 -14
package/main.js CHANGED
@@ -106,15 +106,20 @@ function maskKey(key) {
106
106
  }
107
107
 
108
108
  /**
109
- * Classify a memory-endpoint failure: is this the free-plan cap, or is it just
110
- * offline? aegis1 answers HTTP 402 `free_session_limit_reached` on every
111
- * metered memory endpoint (app.py:9229) and the shared client attaches
112
- * `err.status` / `err.data` (vendor/aegis.js parseResponse) but
113
- * `ipcRenderer.invoke` only carries the *message string* across the process
114
- * boundary. A thrown cap therefore reaches the renderer as the bare text
115
- * "free_session_limit_reached" with `status`/`data` stripped, which is why the
116
- * upgrade UI in fetchMemory() never fired. So: detect it here, in main, where
117
- * the fields still exist, and hand the renderer a plain resolved payload.
109
+ * Classify a memory-endpoint failure: is this the plan's sync quota, or is it
110
+ * just offline? aegis1 answers HTTP 402 `free_session_limit_reached` when a
111
+ * WRITE would exceed the plan's synced-token ceiling (free = 1M, pro = 10M).
112
+ * Since the quota became token-denominated, reads (search/pull) are always
113
+ * served over quota, so only pushes can answer 402 a pull that 402s is an
114
+ * older server.
115
+ *
116
+ * The shared client attaches `err.status` / `err.data` (vendor/aegis.js
117
+ * parseResponse) but `ipcRenderer.invoke` only carries the *message string*
118
+ * across the process boundary. A thrown cap therefore reaches the renderer as
119
+ * the bare text "free_session_limit_reached" with `status`/`data` stripped,
120
+ * which is why the upgrade UI in fetchMemory() never fired. So: detect it here,
121
+ * in main, where the fields still exist, and hand the renderer a plain resolved
122
+ * payload.
118
123
  *
119
124
  * Returns null for anything that is not a cap (offline, no key, 500, …).
120
125
  */
@@ -123,10 +128,19 @@ function upgradeInfo(err) {
123
128
  const data = (err && err.data) || {};
124
129
  const code = typeof data.error === 'string' ? data.error : '';
125
130
  if (status !== 402 && code !== 'free_session_limit_reached') return null;
131
+ // Quota numbers are TOKENS now (`tokensUsed` / `tokenLimit`). The legacy
132
+ // session-named keys are read only as a fallback so this build still shows a
133
+ // number against a server that predates the rename.
134
+ const used = data.tokensUsed != null
135
+ ? data.tokensUsed
136
+ : (data.sessionsUsed != null ? data.sessionsUsed : null);
137
+ const limit = data.tokenLimit != null
138
+ ? data.tokenLimit
139
+ : (data.freeSessionLimit != null ? data.freeSessionLimit : null);
126
140
  return {
127
141
  url: data.upgradeUrl || 'https://aegiscloud.org/subscribe',
128
- used: data.sessionsUsed != null ? data.sessionsUsed : null,
129
- limit: data.freeSessionLimit != null ? data.freeSessionLimit : null,
142
+ used,
143
+ limit,
130
144
  code: code || 'free_session_limit_reached',
131
145
  };
132
146
  }
@@ -164,14 +178,22 @@ async function saveMemoryWithQueue(aegis, dir, entry) {
164
178
  }
165
179
 
166
180
  /**
167
- * Read side of the same normalisation. `memorySearch` / `memoryList` run
168
- * `_memory_sync_access` server-side, so they 402 on exactly the same cap;
169
- * resolving `{ entries: [], upgrade }` keeps one detection path for all four
170
- * memory calls instead of four renderer-side branches that can't see `err.status`.
181
+ * Read side of the same normalisation. Since the sync quota became
182
+ * token-denominated, aegis1 no longer refuses reads over quota — it serves
183
+ * `memorySearch` / `memoryList` and reports `tokensUsed` / `tokenLimit` in the
184
+ * payload. So an exhausted account arrives as a *success*, and the notice has
185
+ * to be derived from the fields rather than from a thrown 402. The 402 branch
186
+ * stays for an older server that still caps reads.
187
+ *
188
+ * Both paths resolve the same `upgrade` shape so the renderer keeps one branch.
171
189
  */
172
190
  async function normalizeMemoryRead(promise) {
173
191
  try {
174
- return await promise;
192
+ const data = await promise;
193
+ const quota = quotaFromPayload(data);
194
+ // Entries are deliberately preserved: an over-quota account still owns its
195
+ // memory and must see it. Only the notice is added.
196
+ return quota ? Object.assign({}, data, { upgrade: quota }) : data;
175
197
  } catch (err) {
176
198
  const upgrade = upgradeInfo(err);
177
199
  if (!upgrade) throw err;
@@ -179,6 +201,22 @@ async function normalizeMemoryRead(promise) {
179
201
  }
180
202
  }
181
203
 
204
+ /** Over-quota notice derived from a successful read payload, or null.
205
+ * `tokensUsed`/`tokenLimit` come from aegis1 `_token_quota_fields`. */
206
+ function quotaFromPayload(data) {
207
+ if (!data || typeof data !== 'object') return null;
208
+ const used = data.tokensUsed != null ? data.tokensUsed : null;
209
+ const limit = data.tokenLimit != null ? data.tokenLimit : null;
210
+ if (used == null || limit == null || limit <= 0) return null;
211
+ if (used < limit) return null;
212
+ return {
213
+ url: 'https://aegiscloud.org/subscribe',
214
+ used,
215
+ limit,
216
+ code: 'sync_quota_reached',
217
+ };
218
+ }
219
+
182
220
  /**
183
221
  * `aegis:memoryImport` — scan this machine for other AI tools' memory and,
184
222
  * when confirmed, push it into AEGIS cloud memory.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aegis-desktop",
3
3
  "productName": "AEGIS Desktop",
4
- "version": "0.3.0",
4
+ "version": "0.3.1",
5
5
  "description": "Thin Electron host for AEGIS — a local chat UI over the shared client/aegis.js transport. Ships transport + UI only; engine logic stays server-side.",
6
6
  "author": {
7
7
  "name": "AEGIS Code",
package/renderer/app.js CHANGED
@@ -160,6 +160,13 @@ const RESERVED_PROVIDERS = new Set(['__aegis', 'aegis']);
160
160
 
161
161
  let pendingEl = null;
162
162
  let pendingSessionId = null;
163
+ // The active thread's session id + prior turns. Both used to reset only on
164
+ // "New Chat" / opening a different session — NOT on every send() — so the
165
+ // model actually sees what was said earlier in the same open conversation
166
+ // instead of starting from a blank slate on every message (the root cause
167
+ // of "the model doesn't remember anything I just said").
168
+ let currentSessionId = null;
169
+ let threadMessages = [];
163
170
  let classOptions = [];
164
171
  let modelMeta = new Map(); // model id -> raw model object from listModels() (P2 §6.3 ceiling)
165
172
 
@@ -444,13 +451,17 @@ async function fetchMemory(query, limit) {
444
451
  // `{ entries: [], upgrade }` instead. Checking the resolved payload first
445
452
  // is what makes the upgrade UI reachable at all — the catch below only
446
453
  // covers a non-IPC caller that still throws the raw client error.
454
+ // An over-quota account arrives as a SUCCESS with `upgrade` attached (reads
455
+ // are served over quota — main.js quotaFromPayload), so the entries must
456
+ // render alongside the notice. Returning `{entries: []}` here is only right
457
+ // for the 402 path, where there is genuinely nothing to show.
458
+ const raw = data && (data.entries || data.results);
459
+ const entries = Array.isArray(raw) ? raw.map(normalizeMemoryEntry) : [];
447
460
  if (data && data.upgrade) {
448
- return { entries: [], error: '', upgrade: data.upgrade };
461
+ return { entries, error: '', upgrade: data.upgrade };
449
462
  }
450
463
  // `entries` is the real key. Keep the `results` fallback only so an older
451
464
  // backend that still sends it degrades to a working list, not an empty one.
452
- const raw = data && (data.entries || data.results);
453
- const entries = Array.isArray(raw) ? raw.map(normalizeMemoryEntry) : [];
454
465
  return { entries, error: '', upgrade: null };
455
466
  } catch (err) {
456
467
  const status = err && err.status;
@@ -461,8 +472,8 @@ async function fetchMemory(query, limit) {
461
472
  error: '',
462
473
  upgrade: {
463
474
  url: (err.data && err.data.upgradeUrl) || 'https://aegiscloud.org/subscribe',
464
- used: err.data && err.data.sessionsUsed,
465
- limit: err.data && err.data.freeSessionLimit,
475
+ used: err.data && (err.data.tokensUsed != null ? err.data.tokensUsed : err.data.sessionsUsed),
476
+ limit: err.data && (err.data.tokenLimit != null ? err.data.tokenLimit : err.data.freeSessionLimit),
466
477
  },
467
478
  };
468
479
  }
@@ -479,12 +490,24 @@ async function fetchMemory(query, limit) {
479
490
  * inspector's block. The hint elements are bare <p>s, so the subscribe link
480
491
  * has to be a real child node — a plain text assignment would wipe it, and an
481
492
  * href left in text is not clickable. */
493
+ /** Human token count for the sync quota: 10000000 -> "10M", 42800 -> "43k".
494
+ * The quota is denominated in tokens (aegis1 FREE_SYNC_TOKENS/PRO_SYNC_TOKENS),
495
+ * which for stored prose is about one per character — rendering the raw
496
+ * integer ("10000000") tells a user nothing at a glance. */
497
+ function formatTokens(n) {
498
+ if (n == null || isNaN(n)) return '?';
499
+ const v = Number(n);
500
+ if (v >= 1e6) return `${(v / 1e6).toFixed(v % 1e6 === 0 ? 0 : 1)}M`;
501
+ if (v >= 1e4) return `${Math.round(v / 1e3)}k`;
502
+ return v.toLocaleString('en-US');
503
+ }
504
+
482
505
  function capNotice(el, upgrade, prefix, cta) {
483
506
  if (!el) return;
484
- const used = upgrade.used != null ? upgrade.used : '?';
485
- const cap = upgrade.limit != null ? upgrade.limit : '?';
507
+ const used = upgrade.used != null ? formatTokens(upgrade.used) : '?';
508
+ const cap = upgrade.limit != null ? formatTokens(upgrade.limit) : '?';
486
509
  el.textContent =
487
- `${prefix || 'free plan limit reached'} — ${used} of ${cap} sync sessions used. ` +
510
+ `${prefix || 'sync limit reached'} — ${used} of ${cap} tokens synced. ` +
488
511
  'Nothing was lost. ';
489
512
  const a = document.createElement('a');
490
513
  a.href = upgrade.url || 'https://aegiscloud.org/subscribe';
@@ -683,11 +706,11 @@ function renderMemoryOverlay() {
683
706
  const h = document.createElement('strong');
684
707
  h.textContent = 'Cloud memory is paused on the free plan';
685
708
  const p = document.createElement('span');
686
- const used = memoryView.upgrade.used != null ? memoryView.upgrade.used : '?';
687
- const cap = memoryView.upgrade.limit != null ? memoryView.upgrade.limit : '?';
709
+ const used = memoryView.upgrade.used != null ? formatTokens(memoryView.upgrade.used) : '?';
710
+ const cap = memoryView.upgrade.limit != null ? formatTokens(memoryView.upgrade.limit) : '?';
688
711
  p.textContent =
689
- `This account has used ${used} of ${cap} sync sessions. Saved memory is ` +
690
- 'not lostit becomes readable again once the plan is active.';
712
+ `This account has used ${used} of ${cap} tokens of synced conversation. ` +
713
+ 'Saved memory is still readable only new saves are paused until there is room again.';
691
714
  const a = document.createElement('a');
692
715
  a.href = memoryView.upgrade.url;
693
716
  a.target = '_blank';
@@ -1703,6 +1726,13 @@ function openSession(id) {
1703
1726
  : 'user';
1704
1727
  addMessage(role, m.content || m.text || '', undefined, role === 'assistant' ? s.id : undefined);
1705
1728
  }
1729
+ // Resuming a past conversation must resume its context too, not just
1730
+ // its on-screen transcript — continuing it as sessionId reuses the same
1731
+ // id and threadMessages carries the prior turns into the next send().
1732
+ currentSessionId = s.id;
1733
+ threadMessages = msgs
1734
+ .filter((m) => m.role === 'user' || m.role === 'assistant')
1735
+ .map((m) => ({ role: m.role, content: m.content || m.text || '' }));
1706
1736
  els.sessionsHint.textContent = `opened ${s.id.slice(0, 8)}…`;
1707
1737
  })
1708
1738
  .catch((err) => {
@@ -1717,6 +1747,8 @@ function newChat() {
1717
1747
  els.sessionsHint.textContent = '';
1718
1748
  pendingEl = null;
1719
1749
  pendingSessionId = null;
1750
+ currentSessionId = null;
1751
+ threadMessages = [];
1720
1752
  flowCount = 0;
1721
1753
  }
1722
1754
 
@@ -1750,9 +1782,19 @@ async function send() {
1750
1782
  // clamping this feeds.
1751
1783
  const effort = autonomous ? els.autonomousEffort.value : undefined;
1752
1784
  const workers = autonomous ? parseInt(els.autonomousWorkers.value, 10) || undefined : undefined;
1753
- const sessionId = newSessionId();
1785
+ // Reuse the open thread's session id (minted once, on its first message)
1786
+ // instead of a fresh one per send — a new id every turn is what made both
1787
+ // the local `messages` history below and the cloud class's server-side
1788
+ // session memory reset on every single message.
1789
+ if (!currentSessionId) currentSessionId = newSessionId();
1790
+ const sessionId = currentSessionId;
1754
1791
  pendingSessionId = sessionId;
1755
1792
 
1793
+ // Snapshot prior turns for the model — the new prompt travels separately
1794
+ // as `prompt` and providers.js appends it after `messages` on the wire.
1795
+ const historyForModel = threadMessages.slice();
1796
+ threadMessages.push({ role: 'user', content: prompt });
1797
+
1756
1798
  setBusy(true, { cancellable: true });
1757
1799
 
1758
1800
  // Persist the user turn locally (best-effort — never blocks chat).
@@ -1789,7 +1831,7 @@ async function send() {
1789
1831
  // All four classes route through the model: surface (the main process
1790
1832
  // decides transport — cloud client, ollama, or a direct provider).
1791
1833
  const data = await models.chat(
1792
- { class: cls, prompt, model, maxTokens, sessionId, autonomous, effort, workers },
1834
+ { class: cls, prompt, model, maxTokens, sessionId, autonomous, effort, workers, messages: historyForModel },
1793
1835
  onDelta
1794
1836
  );
1795
1837
 
@@ -1798,6 +1840,7 @@ async function send() {
1798
1840
  (choice.message && choice.message.content) ||
1799
1841
  streamedText ||
1800
1842
  '(empty response)';
1843
+ threadMessages.push({ role: 'assistant', content: text });
1801
1844
  const bits = [];
1802
1845
  if (data && data.model) bits.push(`model: ${data.model}`);
1803
1846
  else if (model) bits.push(`model: ${model}`);