aegis-desktop 1.0.13 → 1.0.15

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 (96) hide show
  1. package/SOURCE-OFFER.txt +9 -0
  2. package/bin/aegis.js +1 -32
  3. package/lib/account-key.js +1 -56
  4. package/lib/auto.js +1 -430
  5. package/lib/avatar/companion.js +1 -698
  6. package/lib/avatar/cosmetics.js +1 -532
  7. package/lib/avatar/events.js +1 -344
  8. package/lib/avatar/holder-turn.js +1 -350
  9. package/lib/avatar/holders.js +1 -612
  10. package/lib/avatar/identity.js +1 -641
  11. package/lib/avatar/level.js +1 -368
  12. package/lib/avatar/mirror.js +1 -524
  13. package/lib/avatar/persona.js +1 -388
  14. package/lib/avatar/profile.js +1 -977
  15. package/lib/avatar/register.js +1 -307
  16. package/lib/avatar/store.js +1 -472
  17. package/lib/avatar/tiers.js +1 -38
  18. package/lib/avatar/transfer.js +1 -415
  19. package/lib/avatar/turn.js +1 -216
  20. package/lib/avatar/voice.js +1 -328
  21. package/lib/avatar/xp.js +1 -404
  22. package/lib/deep-link.js +1 -120
  23. package/lib/fingerprint.js +2 -347
  24. package/lib/google-connect.js +1 -169
  25. package/lib/local/agents.js +1 -105
  26. package/lib/local/autonomous.js +1 -1045
  27. package/lib/local/claude-pro.js +1 -993
  28. package/lib/local/context.js +1 -95
  29. package/lib/local/engine.js +1 -3228
  30. package/lib/local/entitlement.js +1 -654
  31. package/lib/local/git-scope.js +1 -228
  32. package/lib/local/local.js +1 -2135
  33. package/lib/local/prompt.js +1 -275
  34. package/lib/local/queue.js +1 -560
  35. package/lib/local/session-rounds.js +1 -208
  36. package/lib/local/shell.js +1 -680
  37. package/lib/local/telemetry.js +1 -470
  38. package/lib/local/tools.js +1 -1238
  39. package/lib/local/turn-guard.js +1 -300
  40. package/lib/local/worktree-lock.js +1 -235
  41. package/lib/provider-key-mirror.js +1 -252
  42. package/lib/quick-launcher.js +1 -84
  43. package/lib/settings.js +1 -890
  44. package/lib/sync/memory-queue.js +1 -60
  45. package/lib/sync/persist-gate.js +1 -148
  46. package/lib/sync/sessions.js +1 -106
  47. package/lib/window-state.js +1 -66
  48. package/main.js +3 -4390
  49. package/package.json +3 -5
  50. package/preload.js +3 -628
  51. package/renderer/app.js +1 -6560
  52. package/renderer/avatar/assemble.js +1 -335
  53. package/renderer/avatar/avatar.js +1 -380
  54. package/renderer/avatar/cards.js +1 -141
  55. package/renderer/avatar/hud.js +1 -206
  56. package/renderer/avatar/machine.js +1 -213
  57. package/renderer/avatar/pane.js +1 -439
  58. package/renderer/avatar/parts.js +1 -437
  59. package/renderer/budget.js +1 -168
  60. package/renderer/companion.js +1 -223
  61. package/renderer/diff.js +1 -359
  62. package/renderer/diffview.js +1 -336
  63. package/renderer/fingerprint.js +3 -720
  64. package/renderer/markdown.js +1 -168
  65. package/renderer/provider-toolbar.js +1 -134
  66. package/renderer/providers.js +1 -259
  67. package/renderer/quick.js +1 -191
  68. package/renderer/stream-policy.js +1 -306
  69. package/renderer/transcript-view.js +1 -323
  70. package/renderer/usage.js +1 -1239
  71. package/vendor/aegis.js +1 -2054
  72. package/vendor/attest.js +1 -442
  73. package/vendor/authorship-key.js +1 -410
  74. package/vendor/auto-provision.js +1 -134
  75. package/vendor/brain-catalog.js +1 -268
  76. package/vendor/byok-catalog.js +1 -230
  77. package/vendor/credentials.js +1 -494
  78. package/vendor/device-fingerprint.js +1 -141
  79. package/vendor/drive-connect.js +1 -85
  80. package/vendor/drive-upload.js +1 -174
  81. package/vendor/env-file.js +1 -1273
  82. package/vendor/fingerprint.js +1 -1064
  83. package/vendor/foreign-memory.js +1 -669
  84. package/vendor/google-signin.js +1 -125
  85. package/vendor/hooks.js +1 -430
  86. package/vendor/model-discovery.js +1 -330
  87. package/vendor/platform.js +1 -937
  88. package/vendor/prefs.js +1 -351
  89. package/vendor/presence-constants.js +1 -107
  90. package/vendor/presence.js +1 -438
  91. package/vendor/record-vault.js +3 -1055
  92. package/vendor/session-store.js +1 -548
  93. package/vendor/topup.js +1 -297
  94. package/vendor/touch.js +1 -458
  95. package/vendor/update.js +1 -704
  96. package/vendor/user-models.js +1 -381
@@ -1,534 +1,3 @@
1
1
  // SPDX-License-Identifier: AGPL-3.0-or-later
2
2
  // Copyright (C) 2026 Niklas Borneklint — AEGIS Code (aegiscode) / Aegiscloud.
3
- // AEGIS Code (aegiscode) — source: https://github.com/aegisinfo/aegiscode-plugin
4
- // Conveying a modified copy, or running one as a network service, owes the
5
- // corresponding source under this licence (§5/§13) — see LICENSING.md.
6
- 'use strict';
7
-
8
- /**
9
- * cosmetics.js — the cosmetics pack format and its validator.
10
- *
11
- * WHY A FORMAT AT ALL. Spec §3 says the avatar is *data, not art*: a persona is
12
- * one JSON record and a look is a layered SVG assembled from a part manifest, so
13
- * "adding a seasonal set" is adding a file. `lib/avatar/packs/*.pack.json` is
14
- * that file's shape. A pack is deliberately DUMB — ids, labels and the parts that
15
- * compose a look — because a pack the user can hand-edit is a pack that can be
16
- * checked, and a pack that could do something is a pack that could be a bug.
17
- *
18
- * THE PAYWALL LINE IS A VALIDATOR RULE, NOT A PROMISE (spec §3, §6, invariant 8:
19
- * *sell cosmetics only — never XP, levels, recall breadth, approval friction or
20
- * personalization*). `level.js` owns the unlock table; a pack never declares its
21
- * own price or its own entitlement, it only *references* an unlock id that must
22
- * already exist there, and `validatePack` refuses the pack unless the unlock's
23
- * `paid` flag agrees with the pack's tier and its namespace agrees with the
24
- * pack's kind. `auditPacks` then sweeps the whole pack for anything that smells
25
- * like capability — `xp`, `recallEntries`, `approvals`, `toolGrants`,
26
- * `modelFloor`, `price`, `entitlement`, … — recursively, at any depth, under any
27
- * casing. So a future "paid pack that also raises your recall limit" is not a
28
- * policy violation somebody has to notice in review: it is a pack that fails to
29
- * load. That is the same trick `level.js` uses for the approval carve-outs (make
30
- * the wrong thing unrepresentable) applied to the thing the product sells.
31
- *
32
- * Validation is REPAIR-ORIENTED like `persona.js`, with the difference that the
33
- * hard errors are the point: a pack is an artifact, not the user's own file, so
34
- * an unrepairable pack is dropped (`ok: false`, `pack: null`) rather than coerced
35
- * into something that ships. Unknown *item* ids are the one soft case — a pack
36
- * from a newer build should load with a warning and render the parts it does
37
- * know, rather than blanking a look the user paid for.
38
- *
39
- * Pure apart from `loadPacks`/`shippedPacks`, which read the packs directory and
40
- * are the only reason `node:fs` is imported at all. No Electron, no settings.
41
- */
42
-
43
- const fs = require('node:fs');
44
- const path = require('node:path');
45
- const personaModule = require('./persona.js');
46
- const level = require('./level.js');
47
-
48
- /** Bump when the record shape changes. A newer pack is reported, not guessed at. */
49
- const SCHEMA_VERSION = 1;
50
-
51
- /** What a pack may be *about*. One pack, one kind — a mixed pack cannot be gated. */
52
- const KINDS = Object.freeze(['outfit', 'expressions', 'palette', 'voice']);
53
-
54
- /** Free or paid. `paid` is a claim ABOUT the unlock table, verified below. */
55
- const TIERS = Object.freeze(['free', 'paid']);
56
-
57
- /**
58
- * Pack kind → the unlock-id namespace it is allowed to reference. `expressions`
59
- * is plural in the persona and singular in the unlock table (`expression.core`);
60
- * that mismatch is exactly why this table exists rather than a `startsWith`.
61
- */
62
- const KIND_UNLOCK_PREFIX = Object.freeze({
63
- outfit: 'outfit',
64
- expressions: 'expression',
65
- palette: 'palette',
66
- voice: 'voice',
67
- });
68
-
69
- /** Kind → the `persona.PARTS` list an item id must be drawn from. */
70
- const KIND_PART_LIST = Object.freeze({
71
- outfit: 'outfit',
72
- expressions: 'expressionPack',
73
- palette: 'palette',
74
- voice: null, // voices are not persona parts — see voice.js / BUILTIN_VOICES
75
- });
76
-
77
- /**
78
- * Persona ids and unlock ids are not the same vocabulary — a seasonal SET is
79
- * `seasonal-winter` in the persona and `outfit.seasonal` in the level table.
80
- * This is the canonical alias table; `renderer/avatar/pane.js` carries the same
81
- * pair for the gating lookup and `test/avatar-cosmetics.test.mjs` asserts the
82
- * two agree, so the tables cannot drift apart silently.
83
- */
84
- const UNLOCK_ALIASES = Object.freeze({
85
- 'outfit.seasonal-winter': 'outfit.seasonal',
86
- 'outfit.archive-coat': 'outfit.archive',
87
- });
88
-
89
- /**
90
- * Keys a pack may NEVER carry, matched case-insensitively and at ANY depth.
91
- * Every entry here is a field some other module reads to decide what the user
92
- * is allowed to do: `capabilities()` in level.js, the tool gate, the model
93
- * picker, the recall path — plus the money fields, because pricing and
94
- * entitlement live in the store, never in an artifact the user can edit.
95
- */
96
- const FORBIDDEN_KEYS = Object.freeze([
97
- 'xp', 'xpbonus', 'xpmultiplier', 'level', 'levels', 'unlocklevel', 'minlevel', 'maxlevel',
98
- 'recall', 'recallentries', 'recalltokens', 'memory', 'memorywriteback',
99
- 'approval', 'approvals', 'autoapprove', 'autoapproveclasses', 'approvalfriction',
100
- 'grant', 'grants', 'toolgrant', 'toolgrants', 'capability', 'capabilities',
101
- 'offdevice', 'offdevicegrants', 'modelfloor', 'proactivity', 'queuedrain',
102
- 'importassistant', 'tokens', 'budget', 'pricetokens',
103
- 'price', 'prices', 'amount', 'currency', 'cost', 'sku', 'entitlement', 'entitlements',
104
- 'checkout', 'purchase', 'subscription', 'trial',
105
- // the clone vectors (non-goal: "no voice cloning of real people")
106
- 'sample', 'samples', 'sampleurl', 'recording', 'recordingurl', 'sourceaudio', 'voiceprint',
107
- ]);
108
-
109
- /** Second net: any key we did not name but that reads like a capability knob. */
110
- const FORBIDDEN_KEY_PATTERN = /(xp|level|recall|approval|grant|capabilit|offdevice|proactiv|entitlement|price|clone|voiceprint)/i;
111
-
112
- /** Structural caps. A pack is a cosmetics artifact, not a distribution channel. */
113
- const LIMITS = Object.freeze({
114
- id: 48,
115
- name: 60,
116
- label: 48,
117
- summary: 240,
118
- author: 60,
119
- license: 40,
120
- unlocks: 8,
121
- items: 24,
122
- parts: 6,
123
- });
124
-
125
- /** Only files matching this suffix are loaded — a stray README.json is not a pack. */
126
- const PACK_SUFFIX = '.pack.json';
127
-
128
- const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
129
-
130
- function isPlainObject(value) {
131
- return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
132
- }
133
-
134
- /** The unlock row behind an id, or null. The level table is the only authority. */
135
- function unlockById(id) {
136
- return level.UNLOCKS.find((u) => u.id === id) || null;
137
- }
138
-
139
- /** `outfit.seasonal-winter` → `outfit.seasonal` for the ones that differ. */
140
- function unlockIdFor(kind, itemId) {
141
- return UNLOCK_ALIASES[`${kind}.${itemId}`] || `${kind}.${itemId}`;
142
- }
143
-
144
- /**
145
- * Every forbidden key in a pack-shaped value, at any depth.
146
- *
147
- * @param {unknown} node
148
- * @param {string} [at] path prefix, for the error message
149
- * @returns {string[]} dotted paths, e.g. `["items.2.grants"]`
150
- */
151
- function forbiddenKeys(node, at = '') {
152
- const hits = [];
153
- if (Array.isArray(node)) {
154
- node.forEach((child, i) => hits.push(...forbiddenKeys(child, `${at}${at ? '.' : ''}${i}`)));
155
- return hits;
156
- }
157
- if (!isPlainObject(node)) return hits;
158
- for (const [key, value] of Object.entries(node)) {
159
- const here = `${at}${at ? '.' : ''}${key}`;
160
- const flat = key.toLowerCase().replace(/[^a-z0-9]/g, '');
161
- if (FORBIDDEN_KEYS.includes(flat) || FORBIDDEN_KEY_PATTERN.test(key)) hits.push(here);
162
- hits.push(...forbiddenKeys(value, here));
163
- }
164
- return hits;
165
- }
166
-
167
- function clean(value, max) {
168
- if (typeof value !== 'string') return '';
169
- // Same treatment as persona text: a pack label lands in a UI (and, for an
170
- // expression pack, eventually in a tooltip), so it stays one clean line.
171
- return value.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
172
- }
173
-
174
- /**
175
- * Validate one pack.
176
- *
177
- * @param {unknown} raw parsed JSON (or anything hand-written)
178
- * @returns {{ ok: boolean, pack: object|null, errors: string[], warnings: string[],
179
- * forbidden: string[] }}
180
- * `ok: false` means DO NOT ship it: `pack` is null and `errors` says why. The
181
- * split between errors and warnings is deliberate — an unknown *item id* is a
182
- * warning (a newer build's part renders as a label with no art), while
183
- * anything touching the paywall line is an error.
184
- */
185
- function validatePack(raw) {
186
- const errors = [];
187
- const warnings = [];
188
-
189
- if (!isPlainObject(raw)) {
190
- return {
191
- ok: false,
192
- pack: null,
193
- errors: ['cosmetics pack must be a JSON object'],
194
- warnings,
195
- forbidden: [],
196
- };
197
- }
198
-
199
- // --- the line, checked before anything structural ------------------------
200
- const forbidden = forbiddenKeys(raw);
201
- for (const where of forbidden) {
202
- errors.push(
203
- `cosmetics packs may not carry \`${where}\` — a pack sells LOOKS only, never XP, ` +
204
- 'levels, recall breadth, approval friction or personalization',
205
- );
206
- }
207
-
208
- if (Number.isInteger(raw.schema) && raw.schema > SCHEMA_VERSION) {
209
- errors.push(`pack schema ${raw.schema} is newer than this build supports (${SCHEMA_VERSION})`);
210
- }
211
-
212
- // --- identity -----------------------------------------------------------
213
- const id = clean(raw.id, LIMITS.id);
214
- if (!id || !SLUG.test(id)) {
215
- errors.push(`pack id must be a kebab-case slug (got ${JSON.stringify(raw.id)})`);
216
- }
217
- const name = clean(raw.name, LIMITS.name);
218
- if (!name) errors.push('pack needs a name');
219
-
220
- const kind = KINDS.includes(raw.kind) ? raw.kind : null;
221
- if (!kind) errors.push(`pack kind must be one of ${KINDS.join(' | ')} (got ${JSON.stringify(raw.kind)})`);
222
-
223
- const tier = TIERS.includes(raw.tier) ? raw.tier : null;
224
- if (!tier) errors.push(`pack tier must be "free" or "paid" (got ${JSON.stringify(raw.tier)})`);
225
-
226
- // --- unlocks: the only thing that makes a pack free or paid --------------
227
- const rawUnlocks = Array.isArray(raw.unlocks) ? raw.unlocks : [];
228
- const unlocks = [];
229
- if (!rawUnlocks.length) {
230
- errors.push('pack must reference at least one unlock id from lib/avatar/level.js (a pack cannot create one)');
231
- }
232
- if (rawUnlocks.length > LIMITS.unlocks) {
233
- errors.push(`at most ${LIMITS.unlocks} unlocks per pack (got ${rawUnlocks.length})`);
234
- }
235
- for (const rawUnlock of rawUnlocks.slice(0, LIMITS.unlocks)) {
236
- const unlock = unlockById(rawUnlock);
237
- if (!unlock) {
238
- errors.push(
239
- `unknown unlock ${JSON.stringify(rawUnlock)} — the level table (level.js UNLOCKS) is the only ` +
240
- 'authority on what exists and what is paid',
241
- );
242
- continue;
243
- }
244
- if (kind && unlock.id.split('.')[0] !== KIND_UNLOCK_PREFIX[kind]) {
245
- errors.push(`${unlock.id} is not a ${kind} unlock — a pack may only gate its own kind`);
246
- }
247
- if (tier && unlock.paid !== (tier === 'paid')) {
248
- errors.push(
249
- `tier "${tier}" contradicts unlock ${unlock.id} (paid: ${unlock.paid}) — ` +
250
- 'the level table decides what is paid, not the pack',
251
- );
252
- }
253
- if (!unlocks.includes(unlock.id)) unlocks.push(unlock.id);
254
- }
255
-
256
- // --- items --------------------------------------------------------------
257
- const partListName = kind ? KIND_PART_LIST[kind] : null;
258
- const partList = partListName ? personaModule.PARTS[partListName] : null;
259
- const rawItems = Array.isArray(raw.items) ? raw.items : [];
260
- if (!rawItems.length) errors.push('pack needs at least one item');
261
- if (rawItems.length > LIMITS.items) errors.push(`at most ${LIMITS.items} items per pack (got ${rawItems.length})`);
262
-
263
- const items = [];
264
- const seen = new Set();
265
- for (const rawItem of rawItems.slice(0, LIMITS.items)) {
266
- if (!isPlainObject(rawItem)) {
267
- warnings.push('a non-object item was dropped');
268
- continue;
269
- }
270
- const itemId = clean(rawItem.id, LIMITS.id);
271
- if (!itemId || !SLUG.test(itemId)) {
272
- warnings.push(`item id must be a kebab-case slug (got ${JSON.stringify(rawItem.id)}) — dropped`);
273
- continue;
274
- }
275
- if (seen.has(itemId)) {
276
- warnings.push(`duplicate item ${itemId} — dropped`);
277
- continue;
278
- }
279
- seen.add(itemId);
280
-
281
- let known = true;
282
- if (partList && !partList.includes(itemId)) {
283
- // Soft on purpose: a pack authored for a newer manifest should still load,
284
- // and the pane already renders an id it has no art for as a labelled row.
285
- known = false;
286
- warnings.push(`item ${itemId} is not in this build's ${partListName} manifest — it will render without art`);
287
- }
288
- if (kind === 'voice') {
289
- const engine = String(rawItem.engine || '').trim().toLowerCase();
290
- if (!['local', 'system'].includes(engine)) {
291
- errors.push(
292
- `voice item ${itemId} must name engine "local" or "system" (got ${JSON.stringify(rawItem.engine)}) — ` +
293
- 'voice never leaves the device',
294
- );
295
- known = false;
296
- }
297
- if (rawItem.consent !== 'synthetic') {
298
- errors.push(
299
- `voice item ${itemId} must declare consent: "synthetic" — no pack may ship a voice cloned from a real person`,
300
- );
301
- known = false;
302
- }
303
- }
304
-
305
- const parts = Array.isArray(rawItem.parts)
306
- ? rawItem.parts
307
- .map((p) => clean(p, LIMITS.id))
308
- .filter((p) => Boolean(p))
309
- .slice(0, LIMITS.parts)
310
- : [];
311
- const allParts = new Set(Object.values(personaModule.PARTS).flat().filter((v) => typeof v === 'string'));
312
- for (const part of parts) {
313
- if (!allParts.has(part)) warnings.push(`item ${itemId} names part ${part}, which this build has no art for`);
314
- }
315
-
316
- items.push(
317
- Object.freeze({
318
- id: itemId,
319
- label: clean(rawItem.label, LIMITS.label) || itemId,
320
- known,
321
- parts: Object.freeze(parts),
322
- unlock: unlockIdFor(kind || 'outfit', itemId),
323
- // Carried through for voice items only, and carried through *verbatim*
324
- // rather than dropped: `auditPacks` re-checks `consent` on the loaded
325
- // object, so the boot audit is a second opinion on the raw pack instead
326
- // of a re-run of the validator's own conclusion.
327
- ...(kind === 'voice'
328
- ? { engine: clean(rawItem.engine, 16).toLowerCase(), consent: clean(rawItem.consent, 16).toLowerCase() }
329
- : {}),
330
- }),
331
- );
332
- }
333
-
334
- // Unknown top-level keys are dropped rather than carried, for the same reason
335
- // persona.js drops them: an artifact that accumulates junk is an artifact whose
336
- // contents nobody can reason about.
337
- const knownKeys = ['schema', 'id', 'name', 'kind', 'tier', 'unlocks', 'items', 'author', 'license', 'summary'];
338
- for (const key of Object.keys(raw)) {
339
- if (!knownKeys.includes(key)) warnings.push(`unknown pack key ${JSON.stringify(key)} — ignored`);
340
- }
341
-
342
- if (errors.length) return { ok: false, pack: null, errors, warnings, forbidden };
343
-
344
- return {
345
- ok: true,
346
- errors,
347
- warnings,
348
- forbidden,
349
- pack: Object.freeze({
350
- schema: SCHEMA_VERSION,
351
- id,
352
- name,
353
- kind,
354
- tier,
355
- paid: tier === 'paid',
356
- unlocks: Object.freeze(unlocks),
357
- items: Object.freeze(items),
358
- author: clean(raw.author, LIMITS.author),
359
- license: clean(raw.license, LIMITS.license),
360
- summary: clean(raw.summary, LIMITS.summary),
361
- }),
362
- };
363
- }
364
-
365
- /**
366
- * The paywall line as a function. `validatePack` already refuses a pack whose
367
- * tier contradicts its unlock, so today this can only fire on a pack built by
368
- * something other than the validator (a hand-assembled object, a future loader
369
- * path) — which is exactly why it exists: `store.verifyAtBoot()` runs it over
370
- * the shipped set at launch, so the line fails loudly rather than being trusted.
371
- *
372
- * @returns {string[]} one string per violation, empty when the packs are clean.
373
- */
374
- function auditPacks(packs) {
375
- const violations = [];
376
- for (const pack of Array.isArray(packs) ? packs : []) {
377
- if (!isPlainObject(pack)) continue;
378
- const label = pack.id || '(unnamed pack)';
379
- const bad = forbiddenKeys(pack);
380
- for (const where of bad) violations.push(`${label}: forbidden field ${where}`);
381
-
382
- const tier = pack.tier === 'paid' ? 'paid' : 'free';
383
- const unlocks = Array.isArray(pack.unlocks) ? pack.unlocks : [];
384
- if (!unlocks.length) violations.push(`${label}: no unlock — a pack that grants nothing is not a cosmetic`);
385
- for (const id of unlocks) {
386
- const unlock = unlockById(id);
387
- if (!unlock) {
388
- violations.push(`${label}: unlock ${id} does not exist in the level table`);
389
- continue;
390
- }
391
- if (unlock.paid !== (tier === 'paid')) {
392
- violations.push(`${label}: tier ${tier} contradicts unlock ${id} (paid: ${unlock.paid})`);
393
- }
394
- }
395
- if (pack.kind === 'voice') {
396
- for (const item of pack.items || []) {
397
- if (!item || item.consent !== 'synthetic') {
398
- violations.push(`${label}: voice item ${item && item.id} is not declared synthetic`);
399
- }
400
- }
401
- }
402
- }
403
- return violations;
404
- }
405
-
406
- /** Resolve a pack's directory (shipped packs live next to this file). */
407
- function packsDir(dir) {
408
- return dir || path.join(__dirname, 'packs');
409
- }
410
-
411
- /**
412
- * Read every `*.pack.json` in a directory and validate it.
413
- *
414
- * A broken pack never throws and never takes the others down with it: it comes
415
- * back in `rejected` with its errors, which is what the free/paid UI needs to
416
- * tell the user *why* a set did not appear. Missing directory ⇒ no packs, no
417
- * drama (a build with the directory stripped is not a crash).
418
- *
419
- * @returns {{ packs: object[], rejected: Array<{file: string, errors: string[]}>, warnings: string[], dir: string }}
420
- */
421
- function loadPacks(dir) {
422
- const root = packsDir(dir);
423
- const out = { packs: [], rejected: [], warnings: [], dir: root };
424
- let names;
425
- try {
426
- names = fs.readdirSync(root).filter((n) => n.endsWith(PACK_SUFFIX)).sort();
427
- } catch (err) {
428
- out.warnings.push(`no cosmetics packs at ${root} (${err.code || err.message})`);
429
- return out;
430
- }
431
- for (const file of names) {
432
- let raw;
433
- try {
434
- raw = JSON.parse(fs.readFileSync(path.join(root, file), 'utf8'));
435
- } catch (err) {
436
- out.rejected.push({ file, errors: [`not valid JSON: ${err.message}`] });
437
- continue;
438
- }
439
- const result = validatePack(raw);
440
- for (const warning of result.warnings) out.warnings.push(`${file}: ${warning}`);
441
- if (!result.ok) {
442
- out.rejected.push({ file, errors: result.errors });
443
- continue;
444
- }
445
- out.packs.push(result.pack);
446
- }
447
- return out;
448
- }
449
-
450
- /** Just the shipped packs (throws nothing — see loadPacks). */
451
- function shippedPacks() {
452
- return loadPacks().packs;
453
- }
454
-
455
- /**
456
- * Boot audit over the shipped set: every violation `auditPacks` knows about,
457
- * plus a rejection reason for any pack that will not load at all.
458
- */
459
- function verifyShipped(packs) {
460
- const violations = [];
461
- let list = packs;
462
- if (!Array.isArray(list)) {
463
- const loaded = loadPacks();
464
- list = loaded.packs;
465
- for (const bad of loaded.rejected) {
466
- violations.push(`${bad.file}: ${bad.errors.join('; ')}`);
467
- }
468
- }
469
- violations.push(...auditPacks(list));
470
- return { ok: violations.length === 0, violations, packs: list };
471
- }
472
-
473
- /**
474
- * The free/paid line, joined to the level table — the one structure a docs page,
475
- * a test and (later) the customization pane can all read instead of each
476
- * re-deriving it. Every row names the level that unlocks it, because that is the
477
- * whole claim: **the level earns the cosmetic, money may only ever skip nothing
478
- * but taste.**
479
- *
480
- * @returns {Array<{packId, packName, kind, tier, paid, unlockId, level, label, items: string[]}>}
481
- */
482
- function catalog(packs) {
483
- const rows = [];
484
- for (const pack of Array.isArray(packs) ? packs : []) {
485
- if (!isPlainObject(pack) || !Array.isArray(pack.unlocks)) continue;
486
- for (const unlockId of pack.unlocks) {
487
- const unlock = unlockById(unlockId);
488
- rows.push({
489
- packId: pack.id,
490
- packName: pack.name,
491
- kind: pack.kind,
492
- tier: pack.tier,
493
- paid: Boolean(unlock && unlock.paid),
494
- unlockId,
495
- level: unlock ? unlock.level : null,
496
- label: unlock ? unlock.label : unlockId,
497
- items: (pack.items || []).map((i) => i.id),
498
- });
499
- }
500
- }
501
- return rows;
502
- }
503
-
504
- /** `{ free: [...], paid: [...] }` — the demonstrable line, in one call. */
505
- function freePaidLine(packs) {
506
- const rows = catalog(packs);
507
- return {
508
- free: rows.filter((r) => !r.paid),
509
- paid: rows.filter((r) => r.paid),
510
- };
511
- }
512
-
513
- module.exports = {
514
- SCHEMA_VERSION,
515
- KINDS,
516
- TIERS,
517
- KIND_UNLOCK_PREFIX,
518
- KIND_PART_LIST,
519
- UNLOCK_ALIASES,
520
- FORBIDDEN_KEYS,
521
- LIMITS,
522
- PACK_SUFFIX,
523
- unlockById,
524
- unlockIdFor,
525
- forbiddenKeys,
526
- validatePack,
527
- auditPacks,
528
- packsDir,
529
- loadPacks,
530
- shippedPacks,
531
- verifyShipped,
532
- catalog,
533
- freePaidLine,
534
- };
3
+ "use strict";const fs=require("node:fs"),path=require("node:path"),personaModule=require("./persona.js"),level=require("./level.js"),SCHEMA_VERSION=1,KINDS=Object.freeze(["outfit","expressions","palette","voice"]),TIERS=Object.freeze(["free","paid"]),KIND_UNLOCK_PREFIX=Object.freeze({outfit:"outfit",expressions:"expression",palette:"palette",voice:"voice"}),KIND_PART_LIST=Object.freeze({outfit:"outfit",expressions:"expressionPack",palette:"palette",voice:null}),UNLOCK_ALIASES=Object.freeze({"outfit.seasonal-winter":"outfit.seasonal","outfit.archive-coat":"outfit.archive"}),FORBIDDEN_KEYS=Object.freeze(["xp","xpbonus","xpmultiplier","level","levels","unlocklevel","minlevel","maxlevel","recall","recallentries","recalltokens","memory","memorywriteback","approval","approvals","autoapprove","autoapproveclasses","approvalfriction","grant","grants","toolgrant","toolgrants","capability","capabilities","offdevice","offdevicegrants","modelfloor","proactivity","queuedrain","importassistant","tokens","budget","pricetokens","price","prices","amount","currency","cost","sku","entitlement","entitlements","checkout","purchase","subscription","trial","sample","samples","sampleurl","recording","recordingurl","sourceaudio","voiceprint"]),FORBIDDEN_KEY_PATTERN=/(xp|level|recall|approval|grant|capabilit|offdevice|proactiv|entitlement|price|clone|voiceprint)/i,LIMITS=Object.freeze({id:48,name:60,label:48,summary:240,author:60,license:40,unlocks:8,items:24,parts:6}),PACK_SUFFIX=".pack.json",SLUG=/^[a-z0-9]+(?:-[a-z0-9]+)*$/;function isPlainObject(e){return Boolean(e)&&"object"==typeof e&&!Array.isArray(e)}function unlockById(e){return level.UNLOCKS.find((t=>t.id===e))||null}function unlockIdFor(e,t){return UNLOCK_ALIASES[`${e}.${t}`]||`${e}.${t}`}function forbiddenKeys(e,t=""){const n=[];if(Array.isArray(e))return e.forEach(((e,i)=>n.push(...forbiddenKeys(e,`${t}${t?".":""}${i}`)))),n;if(!isPlainObject(e))return n;for(const[i,s]of Object.entries(e)){const e=`${t}${t?".":""}${i}`,a=i.toLowerCase().replace(/[^a-z0-9]/g,"");(FORBIDDEN_KEYS.includes(a)||FORBIDDEN_KEY_PATTERN.test(i))&&n.push(e),n.push(...forbiddenKeys(s,e))}return n}function clean(e,t){return"string"!=typeof e?"":e.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g," ").replace(/\s+/g," ").trim().slice(0,t)}function validatePack(e){const t=[],n=[];if(!isPlainObject(e))return{ok:!1,pack:null,errors:["cosmetics pack must be a JSON object"],warnings:n,forbidden:[]};const i=forbiddenKeys(e);for(const e of i)t.push(`cosmetics packs may not carry \`${e}\` — a pack sells LOOKS only, never XP, levels, recall breadth, approval friction or personalization`);Number.isInteger(e.schema)&&e.schema>SCHEMA_VERSION&&t.push(`pack schema ${e.schema} is newer than this build supports (${SCHEMA_VERSION})`);const s=clean(e.id,LIMITS.id);s&&SLUG.test(s)||t.push(`pack id must be a kebab-case slug (got ${JSON.stringify(e.id)})`);const a=clean(e.name,LIMITS.name);a||t.push("pack needs a name");const o=KINDS.includes(e.kind)?e.kind:null;o||t.push(`pack kind must be one of ${KINDS.join(" | ")} (got ${JSON.stringify(e.kind)})`);const r=TIERS.includes(e.tier)?e.tier:null;r||t.push(`pack tier must be "free" or "paid" (got ${JSON.stringify(e.tier)})`);const c=Array.isArray(e.unlocks)?e.unlocks:[],l=[];c.length||t.push("pack must reference at least one unlock id from lib/avatar/level.js (a pack cannot create one)"),c.length>LIMITS.unlocks&&t.push(`at most ${LIMITS.unlocks} unlocks per pack (got ${c.length})`);for(const e of c.slice(0,LIMITS.unlocks)){const n=unlockById(e);n?(o&&n.id.split(".")[0]!==KIND_UNLOCK_PREFIX[o]&&t.push(`${n.id} is not a ${o} unlock — a pack may only gate its own kind`),r&&n.paid!==("paid"===r)&&t.push(`tier "${r}" contradicts unlock ${n.id} (paid: ${n.paid}) — the level table decides what is paid, not the pack`),l.includes(n.id)||l.push(n.id)):t.push(`unknown unlock ${JSON.stringify(e)} — the level table (level.js UNLOCKS) is the only authority on what exists and what is paid`)}const u=o?KIND_PART_LIST[o]:null,p=u?personaModule.PARTS[u]:null,d=Array.isArray(e.items)?e.items:[];d.length||t.push("pack needs at least one item"),d.length>LIMITS.items&&t.push(`at most ${LIMITS.items} items per pack (got ${d.length})`);const f=[],k=new Set;for(const e of d.slice(0,LIMITS.items)){if(!isPlainObject(e)){n.push("a non-object item was dropped");continue}const i=clean(e.id,LIMITS.id);if(!i||!SLUG.test(i)){n.push(`item id must be a kebab-case slug (got ${JSON.stringify(e.id)}) — dropped`);continue}if(k.has(i)){n.push(`duplicate item ${i} — dropped`);continue}k.add(i);let s=!0;if(p&&!p.includes(i)&&(s=!1,n.push(`item ${i} is not in this build's ${u} manifest — it will render without art`)),"voice"===o){const n=String(e.engine||"").trim().toLowerCase();["local","system"].includes(n)||(t.push(`voice item ${i} must name engine "local" or "system" (got ${JSON.stringify(e.engine)}) — voice never leaves the device`),s=!1),"synthetic"!==e.consent&&(t.push(`voice item ${i} must declare consent: "synthetic" — no pack may ship a voice cloned from a real person`),s=!1)}const a=Array.isArray(e.parts)?e.parts.map((e=>clean(e,LIMITS.id))).filter((e=>Boolean(e))).slice(0,LIMITS.parts):[],r=new Set(Object.values(personaModule.PARTS).flat().filter((e=>"string"==typeof e)));for(const e of a)r.has(e)||n.push(`item ${i} names part ${e}, which this build has no art for`);f.push(Object.freeze({id:i,label:clean(e.label,LIMITS.label)||i,known:s,parts:Object.freeze(a),unlock:unlockIdFor(o||"outfit",i),..."voice"===o?{engine:clean(e.engine,16).toLowerCase(),consent:clean(e.consent,16).toLowerCase()}:{}}))}const h=["schema","id","name","kind","tier","unlocks","items","author","license","summary"];for(const t of Object.keys(e))h.includes(t)||n.push(`unknown pack key ${JSON.stringify(t)} — ignored`);return t.length?{ok:!1,pack:null,errors:t,warnings:n,forbidden:i}:{ok:!0,errors:t,warnings:n,forbidden:i,pack:Object.freeze({schema:SCHEMA_VERSION,id:s,name:a,kind:o,tier:r,paid:"paid"===r,unlocks:Object.freeze(l),items:Object.freeze(f),author:clean(e.author,LIMITS.author),license:clean(e.license,LIMITS.license),summary:clean(e.summary,LIMITS.summary)})}}function auditPacks(e){const t=[];for(const n of Array.isArray(e)?e:[]){if(!isPlainObject(n))continue;const e=n.id||"(unnamed pack)",i=forbiddenKeys(n);for(const n of i)t.push(`${e}: forbidden field ${n}`);const s="paid"===n.tier?"paid":"free",a=Array.isArray(n.unlocks)?n.unlocks:[];a.length||t.push(`${e}: no unlock — a pack that grants nothing is not a cosmetic`);for(const n of a){const i=unlockById(n);i?i.paid!==("paid"===s)&&t.push(`${e}: tier ${s} contradicts unlock ${n} (paid: ${i.paid})`):t.push(`${e}: unlock ${n} does not exist in the level table`)}if("voice"===n.kind)for(const i of n.items||[])i&&"synthetic"===i.consent||t.push(`${e}: voice item ${i&&i.id} is not declared synthetic`)}return t}function packsDir(e){return e||path.join(__dirname,"packs")}function loadPacks(e){const t=packsDir(e),n={packs:[],rejected:[],warnings:[],dir:t};let i;try{i=fs.readdirSync(t).filter((e=>e.endsWith(PACK_SUFFIX))).sort()}catch(e){return n.warnings.push(`no cosmetics packs at ${t} (${e.code||e.message})`),n}for(const e of i){let i;try{i=JSON.parse(fs.readFileSync(path.join(t,e),"utf8"))}catch(t){n.rejected.push({file:e,errors:[`not valid JSON: ${t.message}`]});continue}const s=validatePack(i);for(const t of s.warnings)n.warnings.push(`${e}: ${t}`);s.ok?n.packs.push(s.pack):n.rejected.push({file:e,errors:s.errors})}return n}function shippedPacks(){return loadPacks().packs}function verifyShipped(e){const t=[];let n=e;if(!Array.isArray(n)){const e=loadPacks();n=e.packs;for(const n of e.rejected)t.push(`${n.file}: ${n.errors.join("; ")}`)}return t.push(...auditPacks(n)),{ok:0===t.length,violations:t,packs:n}}function catalog(e){const t=[];for(const n of Array.isArray(e)?e:[])if(isPlainObject(n)&&Array.isArray(n.unlocks))for(const e of n.unlocks){const i=unlockById(e);t.push({packId:n.id,packName:n.name,kind:n.kind,tier:n.tier,paid:Boolean(i&&i.paid),unlockId:e,level:i?i.level:null,label:i?i.label:e,items:(n.items||[]).map((e=>e.id))})}return t}function freePaidLine(e){const t=catalog(e);return{free:t.filter((e=>!e.paid)),paid:t.filter((e=>e.paid))}}module.exports={SCHEMA_VERSION:SCHEMA_VERSION,KINDS:KINDS,TIERS:TIERS,KIND_UNLOCK_PREFIX:KIND_UNLOCK_PREFIX,KIND_PART_LIST:KIND_PART_LIST,UNLOCK_ALIASES:UNLOCK_ALIASES,FORBIDDEN_KEYS:FORBIDDEN_KEYS,LIMITS:LIMITS,PACK_SUFFIX:PACK_SUFFIX,unlockById:unlockById,unlockIdFor:unlockIdFor,forbiddenKeys:forbiddenKeys,validatePack:validatePack,auditPacks:auditPacks,packsDir:packsDir,loadPacks:loadPacks,shippedPacks:shippedPacks,verifyShipped:verifyShipped,catalog:catalog,freePaidLine:freePaidLine};