aegis-desktop 0.8.3 → 0.8.5

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,695 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * companion.js — the four companion behaviours (PLAN Phase 23, spec §4).
5
+ *
6
+ * The behaviours are the morning brief, the idea log, the session reflection
7
+ * and in-session recall. What they have in common is not their content, it is
8
+ * the rule that makes them shippable at all:
9
+ *
10
+ * **The companion proposes; the user disposes.** This module cannot perform
11
+ * anything. It has no fs, no Electron, no network, no timers and no clock of
12
+ * its own — every fact it talks about is handed to it, every effect it
13
+ * proposes is executed by a host that main passes in, and that host is only
14
+ * ever reached through a *ticket* that `decide(cardId, 'accept')` issued for
15
+ * one specific card. There is deliberately no `autoAccept`, no `level ≥ N`
16
+ * shortcut and no "trusted behaviour" bypass: a level buys whether a card may
17
+ * be *offered*, never whether one may be *acted on*.
18
+ *
19
+ * Four properties, each asserted in `test/avatar-companion.test.mjs`:
20
+ *
21
+ * 1. **Silent at level 1.** `level.capabilities(1)` reports
22
+ * `proactivity: 'off'`, and every behaviour reads that through
23
+ * `gating()`. With capabilities the module does not understand (a
24
+ * hand-written caps object, a future schema) it fails *silent*: unknown
25
+ * capabilities offer nothing rather than everything. That is the opposite
26
+ * of `voice.js`'s "unknown caps never lock anything" convention, and it is
27
+ * deliberate — silence is a safe failure for a proactive surface, noise is
28
+ * not.
29
+ * 2. **Opt-in per behaviour.** `DEFAULT_OPT_IN` is all-false, so a level alone
30
+ * never turns a behaviour on. `capabilities` says *may*, `optIn` says *do*.
31
+ * 3. **One proactive card per session** (`MAX_PROACTIVE_PER_SESSION`, spec
32
+ * §6's "companion becomes noise" risk). A card the user asked for
33
+ * (`user.request`) is not proactive and does not spend the budget.
34
+ * 4. **No effect without an accepted card, and no *tool* without an
35
+ * interactive approval.** `dispatch()` refuses on a missing, forged,
36
+ * replayed or already-consumed ticket, and refuses any intent carrying a
37
+ * `tool` unless the host's approval channel answers `once`/`session`. A
38
+ * host with no approval channel at all is refused too — "I forgot to wire
39
+ * the prompt" must not degrade into "it ran anyway".
40
+ *
41
+ * Effects are declared data (`EFFECTS`), not code paths, so the carve-outs are
42
+ * checkable: nothing here may be a shell, a network call or anything that sends
43
+ * data off the device, and `carveOuts()` is the assertion that says so.
44
+ *
45
+ * Pure: no `fs`, no Electron, no globals. See `docs/avatar-plan.md` §2 and §4.
46
+ */
47
+
48
+ const level = require('./level.js');
49
+
50
+ /** The gate values from `level.js` — one source of truth for the level ladder. */
51
+ const GATES = level.GATES;
52
+
53
+ /** How many proactive cards one session may ever show (spec §6). */
54
+ const MAX_PROACTIVE_PER_SESSION = 1;
55
+
56
+ /** Hard ceiling on anything this module renders into a card, in characters. */
57
+ const MAX_BODY_CHARS = 600;
58
+ const MAX_FACT_CHARS = 160;
59
+ const MAX_FACTS = 6;
60
+ const MAX_CANDIDATES = 8;
61
+
62
+ /**
63
+ * Everything a behaviour is allowed to ask for. A closed table, because "the
64
+ * companion can only do these three things" is a property worth being able to
65
+ * test: every entry is local, and none of them is a shell, a network call or an
66
+ * off-device send.
67
+ */
68
+ const EFFECTS = Object.freeze({
69
+ 'brief.show': Object.freeze({ class: null, tool: null, offDevice: false }),
70
+ 'memory.save': Object.freeze({ class: 'write', tool: null, offDevice: false }),
71
+ 'recall.load': Object.freeze({ class: 'read', tool: 'memory.search', offDevice: false }),
72
+ });
73
+
74
+ /** Approval classes no companion effect may ever carry. */
75
+ const FORBIDDEN_CLASSES = Object.freeze(['shell', 'network', 'offDevice']);
76
+
77
+ /**
78
+ * The behaviour catalogue. `needsProactivity` is the *minimum* proactivity the
79
+ * level must earn before the card may be offered; `proactive` says whether the
80
+ * card is unprompted (and therefore spends the session's one-card budget).
81
+ */
82
+ const CATALOG = Object.freeze([
83
+ Object.freeze({
84
+ id: 'morningBrief',
85
+ label: 'Morning brief',
86
+ // L5+ and only at `proactivity: 'brief'` — the point of the bonus tier.
87
+ needsProactivity: 'brief',
88
+ proactive: true,
89
+ action: 'brief.show',
90
+ acceptLabel: 'Show the brief',
91
+ dismissLabel: 'Not now',
92
+ doneLabel: 'Brief opened',
93
+ }),
94
+ Object.freeze({
95
+ id: 'ideaLog',
96
+ label: 'Idea log',
97
+ // L5+. User-initiated ("remember this idea"), so it is not proactive and
98
+ // does not need the proactivity tier — asking out loud is not noise.
99
+ needsProactivity: 'off',
100
+ proactive: false,
101
+ action: 'memory.save',
102
+ acceptLabel: 'Save to memory',
103
+ dismissLabel: 'Discard',
104
+ doneLabel: 'Saved to memory',
105
+ }),
106
+ Object.freeze({
107
+ id: 'sessionReflection',
108
+ label: 'Session reflection',
109
+ // L10+, offered at session end, at most one line, and only with something
110
+ // to say (a decision made or a thread left open).
111
+ needsProactivity: 'hints',
112
+ proactive: true,
113
+ action: 'memory.save',
114
+ acceptLabel: 'Save this line',
115
+ dismissLabel: 'Discard',
116
+ doneLabel: 'Saved to memory',
117
+ }),
118
+ Object.freeze({
119
+ id: 'recall',
120
+ label: 'Context recall',
121
+ // Breadth scales with level (that is `turn.js`'s recallPolicy), but the
122
+ // *card* only exists once the level is off 'off' — silent at L1.
123
+ needsProactivity: 'hints',
124
+ proactive: true,
125
+ action: 'recall.load',
126
+ acceptLabel: 'Load those notes',
127
+ dismissLabel: 'Skip',
128
+ doneLabel: 'Notes loaded',
129
+ }),
130
+ ]);
131
+
132
+ const BEHAVIOUR_IDS = Object.freeze(CATALOG.map((b) => b.id));
133
+
134
+ /** All-false: a level says *may*, this says *do*. */
135
+ const DEFAULT_OPT_IN = Object.freeze(
136
+ BEHAVIOUR_IDS.reduce((acc, id) => Object.assign(acc, { [id]: false }), {})
137
+ );
138
+
139
+ const PROACTIVITY_ORDER = Object.freeze(['off', 'hints', 'brief']);
140
+
141
+ // --------------------------------------------------------------- small utils
142
+
143
+ function isPlainObject(v) {
144
+ return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
145
+ }
146
+
147
+ /** Strip control characters / line separators and bound the length. */
148
+ function clean(text, max) {
149
+ const s = String(text == null ? '' : text)
150
+ // eslint-disable-next-line no-control-regex
151
+ .replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u2028\u2029]/g, ' ')
152
+ .replace(/\s+/g, ' ')
153
+ .trim();
154
+ const limit = typeof max === 'number' && max > 0 ? max : MAX_FACT_CHARS;
155
+ return s.length > limit ? `${s.slice(0, limit - 1)}…` : s;
156
+ }
157
+
158
+ function atLeast(proactivity, want) {
159
+ const a = PROACTIVITY_ORDER.indexOf(proactivity);
160
+ const b = PROACTIVITY_ORDER.indexOf(want);
161
+ if (a < 0) return false;
162
+ if (b < 0) return true;
163
+ return a >= b;
164
+ }
165
+
166
+ function behaviourById(id) {
167
+ return CATALOG.find((b) => b.id === id) || null;
168
+ }
169
+
170
+ /** The flags `level.capabilities()` publishes for the gated behaviours. */
171
+ function capsFlags(caps) {
172
+ return isPlainObject(caps) && isPlainObject(caps.companion) ? caps.companion : null;
173
+ }
174
+
175
+ // ------------------------------------------------------------------- opt-in
176
+
177
+ /** Normalize an opt-in map. Anything not explicitly `true` is off. */
178
+ function normalizeOptIn(raw) {
179
+ const out = Object.assign({}, DEFAULT_OPT_IN);
180
+ if (!isPlainObject(raw)) return Object.freeze(out);
181
+ for (const id of BEHAVIOUR_IDS) {
182
+ if (raw[id] === true) out[id] = true;
183
+ }
184
+ return Object.freeze(out);
185
+ }
186
+
187
+ // ------------------------------------------------------------------ gating
188
+
189
+ /**
190
+ * Which behaviours this capabilities object + opt-in allow, and why not when it
191
+ * does not. The reason string is user-facing ("unlocks at level 5"), because a
192
+ * checkbox that silently does nothing is the failure mode this table exists to
193
+ * prevent.
194
+ *
195
+ * @param {object} caps `level.capabilities(n)` output (or anything shaped like it).
196
+ * @param {object} [opts]
197
+ * @param {object} [opts.optIn] per-behaviour opt-in (`normalizeOptIn` shape).
198
+ * @param {boolean} [opts.envDisabled] `AEGIS_COMPANION=off` — may only silence.
199
+ * @returns {Object<string, {id: string, allowed: boolean, reason: string}>}
200
+ */
201
+ function gating(caps, opts = {}) {
202
+ const optIn = normalizeOptIn(opts.optIn);
203
+ const flags = capsFlags(caps);
204
+ const proactivity = isPlainObject(caps) && typeof caps.proactivity === 'string' ? caps.proactivity : 'off';
205
+ const out = {};
206
+
207
+ for (const spec of CATALOG) {
208
+ const gate = (allowed, reason) => {
209
+ out[spec.id] = Object.freeze({ id: spec.id, allowed, reason });
210
+ };
211
+
212
+ if (!isPlainObject(caps) || !flags) {
213
+ // Fail silent: an unreadable capabilities object offers nothing.
214
+ gate(false, 'no capabilities — the companion stays quiet');
215
+ continue;
216
+ }
217
+ if (opts.envDisabled === true) {
218
+ gate(false, 'AEGIS_COMPANION=off');
219
+ continue;
220
+ }
221
+ if (PROACTIVITY_ORDER.indexOf(proactivity) < 0) {
222
+ gate(false, `unknown proactivity ${JSON.stringify(proactivity)} — staying quiet`);
223
+ continue;
224
+ }
225
+ if (!atLeast(proactivity, spec.needsProactivity)) {
226
+ gate(false, `${spec.label} unlocks at level ${gateLevelFor(spec)}, not level ${caps.level}`);
227
+ continue;
228
+ }
229
+ if (!flagAllowed(flags, spec)) {
230
+ gate(false, `${spec.label} unlocks at level ${gateLevelFor(spec)}`);
231
+ continue;
232
+ }
233
+ if (optIn[spec.id] !== true) {
234
+ gate(false, `${spec.label} is off (opt-in, default off)`);
235
+ continue;
236
+ }
237
+ gate(true, 'available');
238
+ }
239
+ return Object.freeze(out);
240
+ }
241
+
242
+ function flagAllowed(flags, spec) {
243
+ if (spec.id === 'recall') return true; // breadth, not a flag: the gate is proactivity
244
+ return flags[spec.id] === true;
245
+ }
246
+
247
+ function gateLevelFor(spec) {
248
+ if (spec.id === 'morningBrief') return GATES.brief;
249
+ if (spec.id === 'ideaLog') return GATES.ideaLog;
250
+ if (spec.id === 'sessionReflection') return GATES.sessionReflection;
251
+ return GATES.hints;
252
+ }
253
+
254
+ // ------------------------------------------------------------------ effects
255
+
256
+ /**
257
+ * Which carve-outs this module's effect table violates. Empty array = safe.
258
+ * Mirrors `level.js`'s `carveOuts()` shape so main can assert both at boot.
259
+ */
260
+ function carveOuts() {
261
+ const violations = [];
262
+ for (const spec of CATALOG) {
263
+ const effect = EFFECTS[spec.action];
264
+ if (!effect) {
265
+ violations.push(`${spec.id} proposes undeclared effect ${spec.action}`);
266
+ continue;
267
+ }
268
+ if (effect.offDevice) violations.push(`${spec.id} (${spec.action}) reaches off-device`);
269
+ if (FORBIDDEN_CLASSES.includes(effect.class)) {
270
+ violations.push(`${spec.id} (${spec.action}) carries ${effect.class} approval class`);
271
+ }
272
+ if (effect.tool && !effect.tool.startsWith('memory.')) {
273
+ violations.push(`${spec.id} (${spec.action}) fires tool ${effect.tool}, which is not a memory read`);
274
+ }
275
+ }
276
+ return violations;
277
+ }
278
+
279
+ function assertCarveOuts() {
280
+ const bad = carveOuts();
281
+ if (bad.length) throw new Error(`companion behaviours violate a carve-out: ${bad.join('; ')}`);
282
+ return true;
283
+ }
284
+
285
+ // -------------------------------------------------------------------- facts
286
+
287
+ /**
288
+ * Shape a morning-brief digest out of facts the *caller* gathered locally.
289
+ *
290
+ * This module never looks anything up, so this function is pure formatting: the
291
+ * caller (main, which already has `lib/local/git-scope.js` and
292
+ * `lib/local/queue.js` in hand) reads them and passes the results in. That is
293
+ * what keeps an unprompted card from being an unprompted *tool call*.
294
+ *
295
+ * @param {object} [facts]
296
+ * @param {string[]|number} [facts.changed] changed paths (or a count)
297
+ * @param {string[]|number} [facts.queued] queued task summaries (or a count)
298
+ * @param {string[]} [facts.resuming] memory lines the last session was mid-way through
299
+ */
300
+ function briefDigest(facts = {}) {
301
+ const lines = [];
302
+ const count = (v) => (Array.isArray(v) ? v.length : Number.isFinite(v) ? Number(v) : 0);
303
+
304
+ const changed = count(facts.changed);
305
+ if (changed > 0) {
306
+ const sample = Array.isArray(facts.changed) ? facts.changed.slice(0, 2).map((f) => clean(f, 60)) : [];
307
+ lines.push(`${changed} file${changed === 1 ? '' : 's'} changed since you were last here${sample.length ? ` (${sample.join(', ')})` : ''}`);
308
+ }
309
+ const queued = count(facts.queued);
310
+ if (queued > 0) lines.push(`${queued} task${queued === 1 ? '' : 's'} waiting in the queue`);
311
+
312
+ const resuming = (Array.isArray(facts.resuming) ? facts.resuming : []).map((r) => clean(r, MAX_FACT_CHARS)).filter(Boolean).slice(0, 3);
313
+ if (resuming.length) lines.push(`memory says you were mid-way through: ${resuming.join(' · ')}`);
314
+
315
+ return Object.freeze({
316
+ lines: Object.freeze(lines.slice(0, MAX_FACTS)),
317
+ empty: lines.length === 0,
318
+ });
319
+ }
320
+
321
+ /** Preview lines for a recall card — ids and text only, bounded. */
322
+ function recallCandidates(candidates) {
323
+ const list = Array.isArray(candidates) ? candidates : [];
324
+ return list
325
+ .filter((c) => c && (c.content != null || c.text != null))
326
+ .slice(0, MAX_CANDIDATES)
327
+ .map((c) => Object.freeze({
328
+ id: c.id == null ? null : String(c.id),
329
+ text: clean(c.content != null ? c.content : c.text, MAX_FACT_CHARS),
330
+ }))
331
+ .filter((c) => c.text.length > 0);
332
+ }
333
+
334
+ // -------------------------------------------------------------- card builder
335
+
336
+ function newId(prefix, seq) {
337
+ return `${prefix}-${seq}`;
338
+ }
339
+
340
+ function makeIntent(spec, args) {
341
+ const effect = EFFECTS[spec.action];
342
+ return Object.freeze({
343
+ behaviour: spec.id,
344
+ action: spec.action,
345
+ approvalClass: effect.class,
346
+ tool: effect.tool,
347
+ offDevice: effect.offDevice,
348
+ args: Object.freeze(Object.assign({}, args || {})),
349
+ // A proposal is never pre-approved. Only `decide(id, 'accept')` mints a
350
+ // ticket, and `dispatch()` accepts nothing else.
351
+ approved: false,
352
+ needsInteractiveApproval: effect.tool != null,
353
+ });
354
+ }
355
+
356
+ function makeCard(spec, opts) {
357
+ const actions = Object.freeze([
358
+ Object.freeze({ id: 'accept', decision: 'accept', label: spec.acceptLabel, doneLabel: spec.doneLabel, primary: true }),
359
+ Object.freeze({ id: 'dismiss', decision: 'dismiss', label: spec.dismissLabel, doneLabel: 'Not now', primary: false }),
360
+ ]);
361
+ return Object.freeze({
362
+ id: opts.id,
363
+ // The renderer shapes this exactly like the tool approval card
364
+ // (`renderer/avatar/cards.js`), which is the whole point: it is a UI the
365
+ // user has already learned to read.
366
+ kind: 'companion',
367
+ shape: 'approval',
368
+ behaviour: spec.id,
369
+ label: spec.label,
370
+ proactive: opts.proactive === true,
371
+ title: opts.title,
372
+ body: opts.body,
373
+ facts: Object.freeze((opts.facts || []).slice(0, MAX_FACTS)),
374
+ actions,
375
+ requiresExplicitAccept: true,
376
+ intent: opts.intent,
377
+ });
378
+ }
379
+
380
+ // ------------------------------------------------------------------- session
381
+
382
+ /**
383
+ * One session's worth of companion state. Everything the behaviours need to
384
+ * stay quiet lives here — the proactive budget, which cards were shown, which
385
+ * were accepted, and every ticket ever issued — so "at most one proactive card"
386
+ * and "no effect without an accepted card" are properties of a single object
387
+ * rather than of a caller's discipline.
388
+ *
389
+ * @param {object} [input]
390
+ * @param {object} input.caps `level.capabilities(n)` (required to do anything)
391
+ * @param {object} [input.optIn] per-behaviour opt-in; default all off
392
+ * @param {string} [input.session] session id (stamped on tickets)
393
+ * @param {function} [input.now] clock, injected (never `Date.now` inside)
394
+ * @param {boolean} [input.envDisabled] `AEGIS_COMPANION=off`
395
+ */
396
+ function createSession(input = {}) {
397
+ const caps = input.caps;
398
+ const optIn = normalizeOptIn(input.optIn);
399
+ const sessionId = input.session == null ? 'session' : String(input.session);
400
+ const now = typeof input.now === 'function' ? input.now : () => 0;
401
+ const gates = gating(caps, { optIn, envDisabled: input.envDisabled === true });
402
+
403
+ const cards = new Map();
404
+ const tickets = new Map();
405
+ const decisions = [];
406
+ let seq = 0;
407
+ let proactiveShown = 0;
408
+ let lastBriefDay = input.lastBriefDay == null ? null : String(input.lastBriefDay);
409
+
410
+ function gateOf(id) {
411
+ return gates[id] || Object.freeze({ id, allowed: false, reason: 'unknown behaviour' });
412
+ }
413
+
414
+ function budgetLeft() {
415
+ return proactiveShown < MAX_PROACTIVE_PER_SESSION;
416
+ }
417
+
418
+ /** Common front door for every behaviour: gate, then budget, then nothing. */
419
+ function offer(spec, trigger, builders) {
420
+ const gate = gateOf(spec.id);
421
+ if (!gate.allowed) return { card: null, reason: gate.reason };
422
+ const proactive = trigger.kind !== 'user.request';
423
+ if (proactive && !budgetLeft()) {
424
+ return { card: null, reason: 'already offered a proactive card this session' };
425
+ }
426
+ const built = builders();
427
+ if (!built) return { card: null, reason: 'nothing to report' };
428
+ seq += 1;
429
+ const card = makeCard(spec, Object.assign({ id: newId('companion', seq), proactive }, built));
430
+ cards.set(card.id, { card, decision: null, decidedAt: null });
431
+ if (proactive) proactiveShown += 1;
432
+ return { card, reason: 'offered' };
433
+ }
434
+
435
+ function considerMorningBrief(trigger, spec) {
436
+ const day = trigger.dayKey == null ? null : String(trigger.dayKey);
437
+ if (day && day === lastBriefDay) return { card: null, reason: 'already briefed today' };
438
+ const digest = briefDigest(trigger.facts);
439
+ if (digest.empty) return { card: null, reason: 'nothing to report' };
440
+ return offer(spec, trigger, () => {
441
+ if (day) lastBriefDay = day;
442
+ return {
443
+ title: 'Morning brief',
444
+ body: digest.lines.join(' '),
445
+ facts: digest.lines,
446
+ intent: makeIntent(spec, { lines: digest.lines.slice() }),
447
+ };
448
+ });
449
+ }
450
+
451
+ function considerRecall(trigger, spec) {
452
+ const candidates = recallCandidates(trigger.candidates);
453
+ if (!candidates.length) return { card: null, reason: 'no remembered context for this turn' };
454
+ const query = clean(trigger.query, 120);
455
+ return offer(spec, trigger, () => ({
456
+ title: 'Context recall',
457
+ body: `${candidates.length} remembered note${candidates.length === 1 ? '' : 's'} may be relevant${query ? ` to “${query}”` : ''}. Load them into this turn?`,
458
+ facts: candidates.map((c) => c.text),
459
+ intent: makeIntent(spec, {
460
+ query,
461
+ ids: candidates.map((c) => c.id),
462
+ limit: candidates.length,
463
+ }),
464
+ }));
465
+ }
466
+
467
+ function considerReflection(trigger, spec) {
468
+ const decisions_ = (Array.isArray(trigger.decisions) ? trigger.decisions : []).map((d) => clean(d, MAX_FACT_CHARS)).filter(Boolean);
469
+ const open = (Array.isArray(trigger.open) ? trigger.open : []).map((o) => clean(o, MAX_FACT_CHARS)).filter(Boolean);
470
+ if (!decisions_.length && !open.length) return { card: null, reason: 'nothing to report' };
471
+ const line =
472
+ decisions_.length && open.length
473
+ ? `Decided: ${decisions_[0]} · Still open: ${open[0]}`
474
+ : decisions_.length
475
+ ? `Decided: ${decisions_[0]}`
476
+ : `Still open: ${open[0]}`;
477
+ return offer(spec, trigger, () => ({
478
+ title: 'Session reflection',
479
+ body: `One durable line from this session — save it? ${line}`,
480
+ facts: [line],
481
+ intent: makeIntent(spec, {
482
+ kind: 'reflection',
483
+ content: clean(line, MAX_BODY_CHARS),
484
+ session: sessionId,
485
+ decisions: decisions_.slice(0, MAX_FACTS),
486
+ open: open.slice(0, MAX_FACTS),
487
+ }),
488
+ }));
489
+ }
490
+
491
+ function considerIdea(trigger, spec) {
492
+ const text = clean(trigger.text, MAX_BODY_CHARS);
493
+ if (!text) return { card: null, reason: 'no idea text to save' };
494
+ return offer(spec, trigger, () => ({
495
+ title: 'Idea log',
496
+ body: `Save this to memory as an idea? ${text}`,
497
+ facts: [text],
498
+ intent: makeIntent(spec, { kind: 'idea', content: text, session: sessionId }),
499
+ }));
500
+ }
501
+
502
+ /**
503
+ * The one entry point: hand the companion a real engine trigger, get either a
504
+ * card or a reason. It never performs anything, and calling it twice with the
505
+ * same trigger cannot double-charge a behaviour (the proactive budget).
506
+ *
507
+ * @param {object} trigger
508
+ * @param {'session.start'|'turn.start'|'session.end'|'user.request'} trigger.kind
509
+ * @returns {{card: object|null, reason: string}}
510
+ */
511
+ function consider(trigger = {}) {
512
+ const t = isPlainObject(trigger) ? trigger : {};
513
+ switch (t.kind) {
514
+ case 'session.start':
515
+ return considerMorningBrief(t, behaviourById('morningBrief'));
516
+ case 'turn.start':
517
+ return considerRecall(t, behaviourById('recall'));
518
+ case 'session.end':
519
+ return considerReflection(t, behaviourById('sessionReflection'));
520
+ case 'user.request':
521
+ if (t.want === 'idea') return considerIdea(t, behaviourById('ideaLog'));
522
+ if (t.want === 'recall') return considerRecall(t, behaviourById('recall'));
523
+ if (t.want === 'brief') return considerMorningBrief(t, behaviourById('morningBrief'));
524
+ if (t.want === 'reflection') return considerReflection(t, behaviourById('sessionReflection'));
525
+ return { card: null, reason: `unsupported request ${JSON.stringify(t.want)}` };
526
+ default:
527
+ return { card: null, reason: `unknown trigger ${JSON.stringify(t.kind)}` };
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Resolve one card. `'accept'` mints a single-use ticket; `'dismiss'` mints
533
+ * nothing. A second decision on the same card is refused, so a double click
534
+ * cannot produce two effects.
535
+ */
536
+ function decide(cardId, decision) {
537
+ const rec = cards.get(String(cardId));
538
+ if (!rec) return { ok: false, reason: 'unknown card', decision: null, ticket: null, intent: null };
539
+ if (rec.decision) return { ok: false, reason: `already ${rec.decision}ed`, decision: rec.decision, ticket: null, intent: null };
540
+ if (decision !== 'accept' && decision !== 'dismiss') {
541
+ return { ok: false, reason: 'decision must be "accept" or "dismiss"', decision: null, ticket: null, intent: null };
542
+ }
543
+ rec.decision = decision;
544
+ rec.decidedAt = now();
545
+ decisions.push(Object.freeze({ cardId: rec.card.id, behaviour: rec.card.behaviour, decision, at: rec.decidedAt }));
546
+ if (decision === 'dismiss') return { ok: true, reason: 'dismissed', decision: 'dismiss', ticket: null, intent: null };
547
+
548
+ const ticket = Object.freeze({
549
+ id: `ticket-${sessionId}-${rec.card.id}`,
550
+ cardId: rec.card.id,
551
+ behaviour: rec.card.behaviour,
552
+ session: sessionId,
553
+ approved: true,
554
+ approvedAt: rec.decidedAt,
555
+ intent: rec.card.intent,
556
+ });
557
+ tickets.set(ticket.id, { ticket, used: false });
558
+ return { ok: true, reason: 'accepted', decision: 'accept', ticket, intent: rec.card.intent };
559
+ }
560
+
561
+ /**
562
+ * Was this ticket issued by *this* session, for a card that was accepted?
563
+ * Identity, not shape: a hand-built `{ approved: true, … }` object has no
564
+ * entry in this session's map, so it verifies false. Spending is a separate
565
+ * step (`consumeTicket`) so a replay is reported as a replay rather than as a
566
+ * missing acceptance.
567
+ */
568
+ function verifyTicket(ticket) {
569
+ if (!isPlainObject(ticket) || typeof ticket.id !== 'string') return false;
570
+ const rec = tickets.get(ticket.id);
571
+ return Boolean(rec) && rec.ticket === ticket;
572
+ }
573
+
574
+ function consumeTicket(ticket) {
575
+ const rec = tickets.get(ticket.id);
576
+ if (!rec || rec.ticket !== ticket || rec.used) return false;
577
+ rec.used = true;
578
+ return true;
579
+ }
580
+
581
+ return {
582
+ session: sessionId,
583
+ capabilities: caps,
584
+ optIn,
585
+ // The computed permission table for this session (not the module function).
586
+ gates,
587
+ consider,
588
+ decide,
589
+ verifyTicket,
590
+ consumeTicket,
591
+ /** Cards offered so far (resolved or not), in order. */
592
+ cards: () => Array.from(cards.values()).map((r) => r.card),
593
+ pending: () => Array.from(cards.values()).filter((r) => !r.decision).map((r) => r.card),
594
+ decisions: () => decisions.slice(),
595
+ state: () => Object.freeze({
596
+ session: sessionId,
597
+ proactiveShown,
598
+ maxProactive: MAX_PROACTIVE_PER_SESSION,
599
+ budgetLeft: budgetLeft(),
600
+ lastBriefDay,
601
+ cards: cards.size,
602
+ pending: Array.from(cards.values()).filter((r) => !r.decision).length,
603
+ enabled: BEHAVIOUR_IDS.filter((id) => gateOf(id).allowed),
604
+ }),
605
+ };
606
+ }
607
+
608
+ // ------------------------------------------------------------------ dispatch
609
+
610
+ function refuse(reason) {
611
+ return Object.freeze({ ok: false, performed: false, reason });
612
+ }
613
+
614
+ /**
615
+ * Turn an accepted card into an effect — the ONLY function in this module that
616
+ * can reach a host, and the only place a tool could ever be fired.
617
+ *
618
+ * The gates are ordered so that the cheapest refusal wins, and so that no gate
619
+ * can be satisfied by level, capabilities or opt-in:
620
+ *
621
+ * 1. the ticket must have been issued by this session for this card
622
+ * (`verifyTicket`), or there is no accepted card and nothing happens;
623
+ * 2. the ticket is single-use (`consumeTicket`) — a replayed accept, a
624
+ * re-offered card or a double click cannot fire twice;
625
+ * 3. an intent carrying a `tool` needs the host's approval channel to answer
626
+ * `once` or `session`. No channel, a deny, or a non-answer ⇒ refused;
627
+ * 4. only then is `host.perform(intent)` called.
628
+ *
629
+ * @param {object} ticket from `session.decide(cardId, 'accept')`
630
+ * @param {object} host `{ requestApproval?, perform }` — injected by main
631
+ * @returns {Promise<{ok: boolean, performed: boolean, reason?: string, result?: any}>}
632
+ */
633
+ async function dispatch(session, ticket, host = {}) {
634
+ if (!session || typeof session.verifyTicket !== 'function') {
635
+ return refuse('no companion session — refusing to act');
636
+ }
637
+ if (!session.verifyTicket(ticket)) {
638
+ return refuse('an accepted card is required — the companion never acts on its own');
639
+ }
640
+ if (typeof session.consumeTicket !== 'function' || !session.consumeTicket(ticket)) {
641
+ return refuse('this acceptance was already used');
642
+ }
643
+
644
+ const intent = ticket.intent;
645
+ if (!isPlainObject(intent) || intent.approved !== false) {
646
+ return refuse('malformed intent — refusing to act');
647
+ }
648
+ const effect = EFFECTS[intent.action];
649
+ if (!effect) return refuse(`undeclared effect ${JSON.stringify(intent.action)}`);
650
+
651
+ if (intent.tool) {
652
+ if (typeof host.requestApproval !== 'function') {
653
+ return refuse(`no approval channel for ${intent.tool} — refusing to fire a tool`);
654
+ }
655
+ let decision;
656
+ try {
657
+ decision = await host.requestApproval({
658
+ id: ticket.id,
659
+ behaviour: ticket.behaviour,
660
+ tool: intent.tool,
661
+ args: intent.args,
662
+ approvalClass: intent.approvalClass,
663
+ });
664
+ } catch (err) {
665
+ return refuse(`approval channel failed: ${err && err.message ? err.message : err}`);
666
+ }
667
+ if (decision !== 'once' && decision !== 'session') {
668
+ return refuse(`approval denied for ${intent.tool}`);
669
+ }
670
+ }
671
+
672
+ if (typeof host.perform !== 'function') return refuse('no effect handler configured');
673
+ const result = await host.perform(intent, { ticket });
674
+ return Object.freeze({ ok: true, performed: true, behaviour: ticket.behaviour, action: intent.action, result });
675
+ }
676
+
677
+ module.exports = {
678
+ GATES,
679
+ MAX_PROACTIVE_PER_SESSION,
680
+ MAX_BODY_CHARS,
681
+ BEHAVIOUR_IDS,
682
+ DEFAULT_OPT_IN,
683
+ CATALOG,
684
+ EFFECTS,
685
+ briefDigest,
686
+ recallCandidates,
687
+ normalizeOptIn,
688
+ gating,
689
+ carveOuts,
690
+ assertCarveOuts,
691
+ behaviourById,
692
+ createSession,
693
+ dispatch,
694
+ clean,
695
+ };