aegis-desktop 0.8.12 → 0.8.14

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/auto.js ADDED
@@ -0,0 +1,423 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * auto.js — the two opt-in automations, and the rules that make them safe to
5
+ * leave switched on.
6
+ *
7
+ * BACKGROUND. desktop/main.js and desktop/preload.js both stated an absolute
8
+ * rule when the queue shipped: "NOTHING DRAINS UNLESS THE USER ASKED. There is
9
+ * no interval, no drain at startup, and no drain-on-idle anywhere in this
10
+ * file." That rule was right when the only trigger was a click, and it is still
11
+ * the behaviour of a default install — but it made "queue a task and walk away"
12
+ * impossible, which is the one thing an unattended queue is FOR. The user asked
13
+ * for both automations explicitly (option A of that discussion), so the rule
14
+ * becomes a DEFAULT rather than an absolute, in the only way that keeps the
15
+ * original sentence true: an explicit, persisted, default-off switch, and a
16
+ * tick that does nothing unless it is armed.
17
+ *
18
+ * There are exactly two automations, because there are exactly two acts in the
19
+ * app that are irreversible-ish and cost something:
20
+ *
21
+ * createAutoDrain — runs pending queue tasks on a timer. Spend: a full tool
22
+ * loop on a real model, in a real checkout. Guarded by the queue's own file
23
+ * lock (a second drain is refused by queue.js, not here), by a re-entrancy
24
+ * flag, by an empty-queue check, and by a lock-holder check that skips the
25
+ * tick instead of racing another host for it.
26
+ * createAutoMint — seals the current session as a φ(α) record once the
27
+ * session goes QUIET. Not once per turn: a record is about a session, so a
28
+ * debounce that resets on every finished turn means one record per session
29
+ * that carries all of its turns — human axis and AI axis alike. Publishing
30
+ * is a separate flag and is never implied by sealing.
31
+ *
32
+ * WHAT THIS MODULE DELIBERATELY DOES NOT DO:
33
+ * - It does not decide whether it MAY run. `settings.getAutoDrain()` /
34
+ * `getAutoMint()` are read on every tick, so turning the toggle off stops
35
+ * the very next tick, and the namespaces are reserved
36
+ * (settings.js RESERVED_NAMESPACES) so no provider row can forge one.
37
+ * - It does not take the drain lock, spawn a worker, or write a record. The
38
+ * queue worker owns the lock; lib/fingerprint.js owns the seal. This file
39
+ * owns the TIMING and the skips, and calls in.
40
+ * - It does not swallow a failure. Every skip and every failure is recorded
41
+ * in `state()` with a reason, because an automation that fails silently is
42
+ * indistinguishable from one that is switched off.
43
+ *
44
+ * Timers are injected (`setIntervalFn`/`setTimeoutFn`) so the tests drive the
45
+ * cadence without waiting for it, and so this module never keeps a Node process
46
+ * alive by itself.
47
+ */
48
+
49
+ /** How long a session must be quiet before an armed auto-mint seals it. Long
50
+ * enough that a normal pause for thought does not file a half-finished
51
+ * session, short enough that walking away leaves a record behind. */
52
+ const AUTO_MINT_QUIET_DEFAULT_MS = 90_000;
53
+
54
+ /** Floor: sealing on every turn boundary would file one record per exchange and
55
+ * re-read the whole history each time. */
56
+ const AUTO_MINT_QUIET_MIN_MS = 10_000;
57
+
58
+ /** Ceiling: an hour of quiet. Beyond that the app is closed anyway. */
59
+ const AUTO_MINT_QUIET_MAX_MS = 60 * 60_000;
60
+
61
+ function errorText(err) {
62
+ return (err && err.message) || String(err);
63
+ }
64
+
65
+ /**
66
+ * The auto-drain timer.
67
+ *
68
+ * `proceed` is main.js's drain-all entry point (the same function the card's
69
+ * "Drain all" button calls) — this module never re-implements the loop, the
70
+ * lock, the settle-back-into-the-file write, or the Stop path.
71
+ */
72
+ function createAutoDrain({
73
+ proceed,
74
+ settings,
75
+ queue,
76
+ env = process.env,
77
+ log = () => {},
78
+ setIntervalFn = setInterval,
79
+ clearIntervalFn = clearInterval,
80
+ } = {}) {
81
+ if (typeof proceed !== 'function') {
82
+ throw new Error('createAutoDrain: a proceed() entry point is required');
83
+ }
84
+ if (!settings || typeof settings.getAutoDrain !== 'function') {
85
+ throw new Error('createAutoDrain: the settings store is required');
86
+ }
87
+
88
+ /** The armed timer, or null. `null` is the OFF state and the only one a
89
+ * default install is ever in. */
90
+ let timer = null;
91
+ /** A tick is mid-flight. A drain takes minutes; without this the next tick
92
+ * would stack a second drainAll() on top of the first (queue.js would refuse
93
+ * it at the lock, but only after a round trip and a second set of frames). */
94
+ let busy = false;
95
+ let ticks = 0;
96
+ let drains = 0;
97
+ let last = null;
98
+ let lastSkip = null;
99
+
100
+ const record = (entry) => {
101
+ last = entry;
102
+ log(entry);
103
+ return entry;
104
+ };
105
+
106
+ const skip = (reason, extra) => {
107
+ lastSkip = { at: Date.now(), reason, ...(extra || {}) };
108
+ return lastSkip;
109
+ };
110
+
111
+ function state() {
112
+ const cfg = settings.autoDrainState();
113
+ return {
114
+ ...cfg,
115
+ armed: Boolean(timer),
116
+ busy,
117
+ ticks,
118
+ drains,
119
+ last,
120
+ lastSkip,
121
+ };
122
+ }
123
+
124
+ function arm() {
125
+ const cfg = settings.getAutoDrain();
126
+ // Off is checked BEFORE the "already armed at this cadence" shortcut, or
127
+ // switching off would leave the old timer running (harmless — its tick
128
+ // skips as 'disabled' — but `armed` would read true in the card, which is
129
+ // the kind of lie that makes a user distrust the switch).
130
+ if (!cfg.enabled) {
131
+ stop();
132
+ return state();
133
+ }
134
+ if (timer && cfg.intervalMs === timer.__intervalMs) return state();
135
+ stop();
136
+ timer = setIntervalFn(() => {
137
+ // Fire and forget: a tick's own failures are recorded in `last`, and an
138
+ // unhandled rejection here would take the main process's error handler
139
+ // with it. tick() never throws.
140
+ tick();
141
+ }, cfg.intervalMs);
142
+ timer.__intervalMs = cfg.intervalMs;
143
+ // A drain must never be the reason a process stays alive; in Electron the
144
+ // window is, and in a test the harness is.
145
+ if (timer && typeof timer.unref === 'function') timer.unref();
146
+ log({ type: 'auto-drain-armed', intervalMs: cfg.intervalMs });
147
+ return state();
148
+ }
149
+
150
+ function stop() {
151
+ if (!timer) return state();
152
+ clearIntervalFn(timer);
153
+ timer = null;
154
+ log({ type: 'auto-drain-disarmed' });
155
+ return state();
156
+ }
157
+
158
+ /**
159
+ * One decision, and the only place the guards live.
160
+ *
161
+ * Order matters. `disabled` is first so an armed-then-switched-off timer
162
+ * cannot act on a stale config; `busy` before any file read; `empty` before
163
+ * the lock so an idle install costs one JSON parse and no lock I/O; and the
164
+ * lock-holder check LAST, because taking a lock we would only hand straight
165
+ * back is the one skip that has a real cost (it makes another host's drain
166
+ * fail its own acquire).
167
+ */
168
+ async function tick() {
169
+ ticks += 1;
170
+ const cfg = settings.getAutoDrain();
171
+ if (!cfg.enabled) return record({ type: 'skip', reason: 'disabled' });
172
+ if (busy) return record({ type: 'skip', reason: 'busy' });
173
+ if (!queue || typeof queue.loadQueue !== 'function') {
174
+ return record({ type: 'skip', reason: 'no queue access' });
175
+ }
176
+
177
+ let items;
178
+ try {
179
+ items = queue.loadQueue(env);
180
+ } catch (err) {
181
+ return record({ type: 'error', phase: 'read', error: errorText(err) });
182
+ }
183
+ if (!queue.pendingCount(items)) return record({ type: 'skip', reason: 'empty' });
184
+
185
+ // Another host holds the drain (the CLI's `aegiscode autonomous`, a
186
+ // systemd unit, another window). queue.js's lock would refuse us anyway —
187
+ // this check is so the refusal is a quiet skip rather than a drain attempt
188
+ // whose every frame reports "locked".
189
+ try {
190
+ const holder = queue.lockHolder(env);
191
+ if (holder && holder.pid && holder.pid !== process.pid) {
192
+ const alive = typeof queue.isAlive === 'function' ? queue.isAlive(holder.pid) : true;
193
+ if (alive) return record({ type: 'skip', reason: 'locked', holder });
194
+ }
195
+ } catch {
196
+ // An unreadable lock file is not a reason to skip: queue.js's own
197
+ // acquireLock decides, and a stale/unparseable marker is exactly the case
198
+ // it reclaims immediately.
199
+ }
200
+
201
+ busy = true;
202
+ try {
203
+ const result = await proceed();
204
+ if (result && result.locked) {
205
+ return record({
206
+ type: 'skip',
207
+ reason: 'locked',
208
+ holder: result.holder || null,
209
+ });
210
+ }
211
+ drains += 1;
212
+ return record({
213
+ type: 'drain',
214
+ ok: !(result && result.ok === false),
215
+ ran: (result && result.ran) || [],
216
+ reason: (result && result.reason) || null,
217
+ });
218
+ } catch (err) {
219
+ return record({ type: 'error', phase: 'drain', error: errorText(err) });
220
+ } finally {
221
+ busy = false;
222
+ }
223
+ }
224
+
225
+ return {
226
+ state,
227
+ /** Read the persisted switch and arm/disarm to match. Called at boot and
228
+ * after every toggle — never called by a timer. */
229
+ start: arm,
230
+ arm,
231
+ stop,
232
+ tick,
233
+ /** The card's toggle. Persists through the settings store, then re-arms,
234
+ * so the answer the renderer paints is the answer the timer is running.
235
+ * Without the re-arm the switch would persist and do nothing until the next
236
+ * restart — the exact silent failure this test file exists to catch. */
237
+ set(patch) {
238
+ settings.setAutoDrain(patch || {});
239
+ arm();
240
+ return state();
241
+ },
242
+ /** Test seam: whatever is armed, for a harness that injects its own timer
243
+ * and needs to fire it. */
244
+ _timer: () => timer,
245
+ };
246
+ }
247
+
248
+ /**
249
+ * The auto-mint (seal-on-quiet) timer.
250
+ *
251
+ * `mint` is lib/fingerprint.js's own `mint()` — the same call the panel's "Seal
252
+ * this session" button makes, reading the same session log through the same
253
+ * shared adapter, so an automatic record and a hand-sealed one of the same
254
+ * session are the same record.
255
+ */
256
+ function createAutoMint({
257
+ mint,
258
+ settings,
259
+ log = () => {},
260
+ quietMs = AUTO_MINT_QUIET_DEFAULT_MS,
261
+ setTimeoutFn = setTimeout,
262
+ clearTimeoutFn = clearTimeout,
263
+ } = {}) {
264
+ if (typeof mint !== 'function') throw new Error('createAutoMint: mint() is required');
265
+ if (!settings || typeof settings.getAutoMint !== 'function') {
266
+ throw new Error('createAutoMint: the settings store is required');
267
+ }
268
+
269
+ const quiet = (() => {
270
+ const n = Number(quietMs);
271
+ if (!Number.isFinite(n)) return AUTO_MINT_QUIET_DEFAULT_MS;
272
+ const ms = Math.round(n);
273
+ if (ms < AUTO_MINT_QUIET_MIN_MS || ms > AUTO_MINT_QUIET_MAX_MS) {
274
+ return AUTO_MINT_QUIET_DEFAULT_MS;
275
+ }
276
+ return ms;
277
+ })();
278
+
279
+ /** The pending debounce, or null. */
280
+ let timer = null;
281
+ /** Turns that arrived since the last seal. The ONLY thing that makes a seal
282
+ * due: without it, a quiet period with no new work would re-seal the same
283
+ * session over and over, one identical record per quiet minute. */
284
+ let pendingTurns = 0;
285
+ let busy = false;
286
+ let seals = 0;
287
+ let failures = 0;
288
+ let last = null;
289
+ let lastError = null;
290
+ let lastSeal = null;
291
+
292
+ function state() {
293
+ const cfg = settings.autoMintState();
294
+ return {
295
+ ...cfg,
296
+ armed: Boolean(timer),
297
+ quietMs: quiet,
298
+ pendingTurns,
299
+ busy,
300
+ seals,
301
+ failures,
302
+ last,
303
+ lastError,
304
+ lastSeal,
305
+ };
306
+ }
307
+
308
+ function disarm() {
309
+ if (!timer) return;
310
+ clearTimeoutFn(timer);
311
+ timer = null;
312
+ }
313
+
314
+ /** A turn finished. Restart the quiet clock; do not seal anything now. */
315
+ function note(info) {
316
+ const cfg = settings.getAutoMint();
317
+ if (!cfg.enabled) {
318
+ // Counted even while off, so switching the toggle on mid-session seals a
319
+ // session that already has work in it rather than waiting for the next
320
+ // turn — which is what a user who just flipped the switch means.
321
+ pendingTurns += 1;
322
+ return state();
323
+ }
324
+ pendingTurns += 1;
325
+ last = { type: 'turn', at: Date.now(), sessionId: (info && info.sessionId) || null };
326
+ disarm();
327
+ timer = setTimeoutFn(() => {
328
+ timer = null;
329
+ flush();
330
+ }, quiet);
331
+ if (timer && typeof timer.unref === 'function') timer.unref();
332
+ return state();
333
+ }
334
+
335
+ /**
336
+ * Seal now, if there is anything to seal. This is what the debounce timer
337
+ * calls; it is also the seam a test drives directly instead of waiting.
338
+ */
339
+ async function flush() {
340
+ const cfg = settings.getAutoMint();
341
+ if (!cfg.enabled) return { type: 'skip', reason: 'disabled' };
342
+ if (busy) return { type: 'skip', reason: 'busy' };
343
+ if (!pendingTurns) return { type: 'skip', reason: 'nothing new' };
344
+ busy = true;
345
+ // Claimed BEFORE the call: a seal that throws must not leave the counter
346
+ // armed, or every later quiet period retries a failure that will not heal
347
+ // (no key, unreadable history) and files a new error each time.
348
+ const claimed = pendingTurns;
349
+ pendingTurns = 0;
350
+ try {
351
+ // `aiSecret` is left undefined on purpose: that makes lib/fingerprint.js
352
+ // walk the shared rungs in client/attest.js, so the AI axis is attested
353
+ // from `$AEGIS_AI_SECRET` in the app's scope and `aiModel` from the same
354
+ // place the CLI resolves it. This host never invents either, and never
355
+ // borrows the authorship key for the AI axis (they answer different
356
+ // questions: who sealed it, and which mind answered).
357
+ const result = await mint({ publish: cfg.publish === true });
358
+ if (!result || result.ok === false) {
359
+ failures += 1;
360
+ lastError = (result && result.error) || 'the seal did not complete';
361
+ return (last = { type: 'error', phase: 'seal', error: lastError });
362
+ }
363
+ seals += 1;
364
+ lastError = null;
365
+ lastSeal = {
366
+ at: Date.now(),
367
+ id: (result.record && result.record.id) || null,
368
+ out: result.out || null,
369
+ turns: claimed,
370
+ // Reported separately by lib/fingerprint.js: the seal is the evidence,
371
+ // the cloud copy is the convenience, and a failed publish never
372
+ // unseals anything.
373
+ vaultError: result.vaultError || null,
374
+ };
375
+ return (last = { type: 'seal', ...lastSeal });
376
+ } catch (err) {
377
+ failures += 1;
378
+ lastError = errorText(err);
379
+ return (last = { type: 'error', phase: 'seal', error: lastError });
380
+ } finally {
381
+ busy = false;
382
+ }
383
+ }
384
+
385
+ return {
386
+ state,
387
+ note,
388
+ flush,
389
+ /** Disarm the debounce and forget the pending count — the renderer's
390
+ * explicit "seal now" path calls this after sealing, so the record it just
391
+ * filed is not filed again by the timer. */
392
+ reset() {
393
+ disarm();
394
+ pendingTurns = 0;
395
+ return state();
396
+ },
397
+ start() {
398
+ // Arming at boot means "the switch is on", not "seal immediately": with
399
+ // pendingTurns at 0 there is nothing to seal until a turn arrives.
400
+ if (!settings.getAutoMint().enabled) disarm();
401
+ return state();
402
+ },
403
+ stop() {
404
+ disarm();
405
+ return state();
406
+ },
407
+ set(patch) {
408
+ settings.setAutoMint(patch || {});
409
+ if (!settings.getAutoMint().enabled) disarm();
410
+ return state();
411
+ },
412
+ /** Test seam: is the debounce armed? */
413
+ _timer: () => timer,
414
+ };
415
+ }
416
+
417
+ module.exports = {
418
+ createAutoDrain,
419
+ createAutoMint,
420
+ AUTO_MINT_QUIET_DEFAULT_MS,
421
+ AUTO_MINT_QUIET_MIN_MS,
422
+ AUTO_MINT_QUIET_MAX_MS,
423
+ };
@@ -6,7 +6,7 @@
6
6
  "tier": "free",
7
7
  "unlocks": ["outfit.hoodie", "outfit.tshirt"],
8
8
  "author": "AEGIS Code",
9
- "license": "MIT",
9
+ "license": "AGPL-3.0-or-later",
10
10
  "summary": "The two starter outfits. Yours from level 1, and never sold back to you.",
11
11
  "items": [
12
12
  { "id": "hoodie", "label": "Hoodie", "parts": ["hoodie"] },
@@ -6,7 +6,7 @@
6
6
  "tier": "free",
7
7
  "unlocks": ["voice.core"],
8
8
  "author": "AEGIS Code",
9
- "license": "MIT",
9
+ "license": "AGPL-3.0-or-later",
10
10
  "summary": "Three bundled synthetic voices. Local engine only, off until the user turns it on, and nothing is ever sent anywhere to say a sentence.",
11
11
  "items": [
12
12
  { "id": "aegis-neutral", "label": "Neutral", "engine": "local", "consent": "synthetic" },
@@ -6,7 +6,7 @@
6
6
  "tier": "paid",
7
7
  "unlocks": ["outfit.seasonal"],
8
8
  "author": "AEGIS Code",
9
- "license": "MIT",
9
+ "license": "AGPL-3.0-or-later",
10
10
  "summary": "A cosmetic pack. Paid packs sell looks only: this changes how the companion is drawn and nothing about what it can do.",
11
11
  "items": [
12
12
  { "id": "seasonal-winter", "label": "Winter coat", "parts": ["seasonal-winter", "scarf"] }
@@ -140,7 +140,16 @@ function keyOf(value) {
140
140
  }
141
141
 
142
142
  function asArray(value) {
143
- return Array.isArray(value) ? value : [];
143
+ if (Array.isArray(value)) return value;
144
+ // A Set is what the folder builders accumulate source ids into (`ids: new
145
+ // Set()` in foldVocabulary/foldLanguage/foldCodebase). Without this branch
146
+ // `asArray(set)` returned [], so `citable(rec.ids)` was always empty, so
147
+ // `makeFacet` returned null for want of a source: vocabulary, language and
148
+ // codebase facets could never be produced at all — three of the seven classes
149
+ // were dead code, and `coldStart` stayed true for a holder whose only evidence
150
+ // was theirs.
151
+ if (value instanceof Set) return [...value];
152
+ return [];
144
153
  }
145
154
 
146
155
  function textOf(entry) {