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.
- package/lib/avatar/companion.js +695 -0
- package/lib/avatar/cosmetics.js +529 -0
- package/lib/avatar/events.js +341 -0
- package/lib/avatar/holder-turn.js +348 -0
- package/lib/avatar/holders.js +610 -0
- package/lib/avatar/identity.js +638 -0
- package/lib/avatar/level.js +365 -0
- package/lib/avatar/mirror.js +522 -0
- package/lib/avatar/packs/README.md +47 -0
- package/lib/avatar/packs/core-wardrobe.pack.json +15 -0
- package/lib/avatar/packs/local-voice-core.pack.json +16 -0
- package/lib/avatar/packs/seasonal-winter.pack.json +14 -0
- package/lib/avatar/persona.js +385 -0
- package/lib/avatar/profile.js +966 -0
- package/lib/avatar/register.js +304 -0
- package/lib/avatar/store.js +469 -0
- package/lib/avatar/tiers.js +35 -0
- package/lib/avatar/transfer.js +413 -0
- package/lib/avatar/turn.js +213 -0
- package/lib/avatar/voice.js +325 -0
- package/lib/avatar/xp.js +401 -0
- package/lib/local/engine.js +24 -0
- package/lib/settings.js +67 -0
- package/main.js +52 -6
- package/package.json +8 -3
- package/renderer/app.js +90 -15
- package/renderer/avatar/assemble.js +332 -0
- package/renderer/avatar/avatar.css +260 -0
- package/renderer/avatar/avatar.js +377 -0
- package/renderer/avatar/cards.js +139 -0
- package/renderer/avatar/hud.js +204 -0
- package/renderer/avatar/machine.js +212 -0
- package/renderer/avatar/pane.js +436 -0
- package/renderer/avatar/parts.js +434 -0
- package/renderer/stream-policy.js +129 -0
- package/renderer/usage.js +372 -41
|
@@ -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
|
+
};
|