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,614 +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
- * holders.js — who holds the memory.
10
- *
11
- * `identity.js` decides *which* holder a set of signals points at and
12
- * refuses to merge two people on a fingerprint match. `profile.js`
13
- * folds one holder's entries into bounded, evidence-gated facets. Neither of
14
- * them owns a directory. This file is that owner — the layout, the registry, the
15
- * switch, the conflict decision and the "why does it know this" surface — and it
16
- * is deliberately still pure Node (fs + path only, no Electron, no clock unless
17
- * one is injected) so the whole phase is testable with `node --test`.
18
- *
19
- * The four claims this module exists to make true:
20
- *
21
- * 1. **A switch swaps everything together.** `switchHolder()` returns a state
22
- * read fresh off disk for the new holder — ledger, persona and profile
23
- * cache — and names the holder's memory scope in the same object. There is
24
- * no field carried over from the previous holder, and the only thing that
25
- * survives in memory is the registry (which is an *index*, not data). The
26
- * claim is asserted by comparing the two states byte-for-byte for any field
27
- * that could carry holder A's text.
28
- *
29
- * 2. **A fingerprint is not an identity.** `applyConflict()` has one default:
30
- * *keep separate*. Rebinding two holders behind one fingerprint requires
31
- * `{ choice: 'rebind', confirm: true }` — the card's explicit confirm — and
32
- * even then it only copies the fingerprint onto the surviving row. Nothing
33
- * in this file ever merges two ledgers, two personas or two profiles.
34
- *
35
- * 3. **Isolation is applied at the seam, not hoped for.** `entriesForHolder()`
36
- * is the client-side filter §3 calls "the assertion that the fix happened":
37
- * the server-side scope is the fix, and this is what proves it ran. It is
38
- * used by the fold *and* by the recall block, so a leak needs to defeat one
39
- * function rather than two call sites — and the test drives two
40
- * holders through the real turn assembly and greps the prompt.
41
- *
42
- * 4. **Every claim is traceable and deletable.** `provenance()` renders a
43
- * facet's `sources` as the underlying entry text, and `forgetFacet()`
44
- * removes the facet *and* the entries behind it: the ids go on the holder's
45
- * own forget list, which `entriesForHolder()` honours on the next fold, so
46
- * the claim cannot silently re-derive from the same row (§4).
47
- *
48
- * What is NOT here: any level arithmetic (that is `xp.js` + `store.js`), any
49
- * prompt text (that is `holder-turn.js`), and any IPC (that is `holder-ipc.js`).
50
- */
51
-
52
- const fs = require('node:fs');
53
- const path = require('node:path');
54
-
55
- const identity = require('./identity.js');
56
- const personaModule = require('./persona.js');
57
- const profileModule = require('./profile.js');
58
- const xp = require('./xp.js');
59
-
60
- /** `<userData>/avatar/` — the one directory tree this module owns. */
61
- const AVATAR_DIR = 'avatar';
62
- const REGISTRY_FILE = 'holders.json';
63
- const HOLDERS_DIR = 'holders';
64
- const LEDGER_FILE = 'ledger.jsonl';
65
- const PERSONA_FILE = 'persona.json';
66
- const PROFILE_FILE = 'profile.json';
67
- const FORGOTTEN_FILE = 'forgotten.json';
68
-
69
- /**
70
- * The one thing a switch may leave behind. A list, not a sentence: the
71
- * test iterates it, so adding a survivor to the code without adding it here
72
- * makes the test fail rather than the promise quietly rot.
73
- */
74
- const SURVIVES_SWITCH = Object.freeze(['registry']);
75
-
76
- /** Reserved mirror sessions (§6) — holder id, never a fingerprint. */
77
- const MIRROR_SESSIONS = Object.freeze(['avatar:ledger', 'avatar:profile']);
78
- function mirrorSession(holderId, kind = 'ledger') {
79
- const suffix = kind === 'profile' ? 'profile' : 'ledger';
80
- return `avatar:${suffix}:${holderId}`;
81
- }
82
-
83
- // ---------------------------------------------------------------- paths ----
84
-
85
- function registryPath(dir) {
86
- return path.join(String(dir), AVATAR_DIR, REGISTRY_FILE);
87
- }
88
-
89
- /** The holder's own directory. Ids are shape-checked before they touch a path. */
90
- function holderDir(dir, holderId) {
91
- const id = safeHolderId(holderId);
92
- if (!id) throw new Error('holderDir: expected a holder id (h_ + 8-16 hex)');
93
- return path.join(String(dir), AVATAR_DIR, HOLDERS_DIR, id);
94
- }
95
-
96
- function holderPaths(dir, holderId) {
97
- const base = holderDir(dir, holderId);
98
- return {
99
- dir: base,
100
- ledger: path.join(base, LEDGER_FILE),
101
- persona: path.join(base, PERSONA_FILE),
102
- profile: path.join(base, PROFILE_FILE),
103
- forgotten: path.join(base, FORGOTTEN_FILE),
104
- };
105
- }
106
-
107
- function safeHolderId(value) {
108
- const id = value == null ? '' : String(value).trim();
109
- if (!id) return null;
110
- return identity.HOLDER_ID_RE && identity.HOLDER_ID_RE.test(id) ? id : null;
111
- }
112
-
113
- // -------------------------------------------------------------- file IO ----
114
-
115
- function readJsonFile(file) {
116
- try {
117
- const raw = fs.readFileSync(file, 'utf8');
118
- const parsed = JSON.parse(raw);
119
- return parsed && typeof parsed === 'object' ? parsed : null;
120
- } catch (err) {
121
- return null;
122
- }
123
- }
124
-
125
- function writeJsonFile(file, value) {
126
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
127
- const tmp = `${file}.tmp`;
128
- fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
129
- fs.renameSync(tmp, file);
130
- return value;
131
- }
132
-
133
- /** Parse a JSONL ledger. Tolerant by construction: a torn last line is skipped. */
134
- function readLedger(file) {
135
- let raw = '';
136
- try {
137
- raw = fs.readFileSync(file, 'utf8');
138
- } catch (err) {
139
- return [];
140
- }
141
- const rows = [];
142
- for (const line of raw.split('\n')) {
143
- const trimmed = line.trim();
144
- if (!trimmed) continue;
145
- try {
146
- const row = JSON.parse(trimmed);
147
- if (row && typeof row === 'object') rows.push(row);
148
- } catch (err) {
149
- // A half-written append is lost XP, never a crash — same rule as store.js.
150
- }
151
- }
152
- return rows;
153
- }
154
-
155
- function appendLedgerRow(file, row) {
156
- fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
157
- fs.appendFileSync(file, `${JSON.stringify(row)}\n`);
158
- return row;
159
- }
160
-
161
- // ------------------------------------------------------------ registry ----
162
-
163
- /**
164
- * Read the registry. Anything unreadable or invalid degrades to a fresh empty
165
- * registry (identity.load validates and migrates), because "the index is gone"
166
- * must never read as "your memory is gone" — the holder directories are still
167
- * on disk and a registry rebuild is not a data loss.
168
- */
169
- function readRegistry(dir) {
170
- const raw = readJsonFile(registryPath(dir));
171
- try {
172
- return identity.load(raw || identity.emptyRegistry());
173
- } catch (err) {
174
- return identity.load(identity.emptyRegistry());
175
- }
176
- }
177
-
178
- /** Validate + persist. Returns the validated registry, never the raw input. */
179
- function writeRegistry(dir, registry) {
180
- const validated = identity.load(registry);
181
- writeJsonFile(registryPath(dir), validated);
182
- return validated;
183
- }
184
-
185
- function holderRow(registry, holderId) {
186
- const id = safeHolderId(holderId);
187
- if (!id) return null;
188
- return (registry.holders || []).find((row) => row && row.id === id) || null;
189
- }
190
-
191
- /**
192
- * Add a holder (idempotent by id and by fingerprint-free identity) and return
193
- * `{ registry, row, created }`. Used by a first-run mint and by the conflict
194
- * card's "keep separate", which is exactly `ensureHolder` with no fingerprint.
195
- */
196
- function ensureHolder(dir, input = {}) {
197
- const registry = input.registry || readRegistry(dir);
198
- const wanted = input.id ? safeHolderId(input.id) : null;
199
- const existing = wanted ? holderRow(registry, wanted) : null;
200
- if (existing) return { registry, row: existing, created: false };
201
-
202
- const withRow = identity.addHolder(registry, {
203
- id: wanted || undefined,
204
- displayName: input.displayName,
205
- fingerprint: input.fingerprint,
206
- accountId: input.accountId,
207
- now: input.now,
208
- });
209
- const validated = identity.load(withRow);
210
- const row = validated.holders[validated.holders.length - 1] || null;
211
- if (input.persist !== false) writeRegistry(dir, validated);
212
- return { registry: validated, row, created: true };
213
- }
214
-
215
- // --------------------------------------------------------------- state -----
216
-
217
- /** What a level reads as, from this holder's ledger rows alone. */
218
- function levelOf(ledger) {
219
- try {
220
- const evaluated = xp.evaluate(Array.isArray(ledger) ? ledger : []);
221
- return evaluated && Number.isFinite(evaluated.level) ? evaluated.level : 1;
222
- } catch (err) {
223
- return 1;
224
- }
225
- }
226
-
227
- /** The forgotten-entry list: what "forget" actually writes. */
228
- function readForgotten(dir, holderId) {
229
- const parsed = readJsonFile(holderPaths(dir, holderId).forgotten);
230
- const ids = parsed && Array.isArray(parsed.entries) ? parsed.entries : [];
231
- return new Set(ids.map(String));
232
- }
233
-
234
- function writeForgotten(dir, holderId, ids) {
235
- const list = Array.from(new Set(Array.from(ids).map(String))).sort();
236
- writeJsonFile(holderPaths(dir, holderId).forgotten, {
237
- schema: 1,
238
- holderId,
239
- entries: list,
240
- });
241
- return new Set(list);
242
- }
243
-
244
- function emptyProfile(holderId) {
245
- return { schema: 1, holderId, facets: [], budget: { tokens: 0, used: 0 }, coldStart: true };
246
- }
247
-
248
- /**
249
- * Load one holder's state — every field read from that holder's own directory,
250
- * nothing defaulted from a previous holder.
251
- *
252
- * @returns {{ holderId:string, displayName:string, ledger:object[],
253
- * persona:object, profile:object, forgotten:Set<string>,
254
- * level:number, scope:string[] }}
255
- */
256
- function loadState(dir, holderId, opts = {}) {
257
- const id = safeHolderId(holderId);
258
- if (!id) throw new Error('loadState: expected a holder id (h_ + 8-16 hex)');
259
- const registry = opts.registry || readRegistry(dir);
260
- const row = holderRow(registry, id);
261
- const paths = holderPaths(dir, id);
262
- const ledger = readLedger(paths.ledger);
263
-
264
- const rawPersona = readJsonFile(paths.persona);
265
- let persona = personaModule.defaultPersona();
266
- if (rawPersona) {
267
- try {
268
- const loaded = personaModule.load(rawPersona);
269
- persona = loaded && loaded.persona ? loaded.persona : loaded;
270
- } catch (err) {
271
- persona = personaModule.defaultPersona();
272
- }
273
- } else {
274
- // A holder with no persona file inherits nothing: it gets the neutral
275
- // default, so a fresh holder is an unconfigured install (§4, cold start).
276
- persona = personaModule.defaultPersona();
277
- }
278
-
279
- const profile = readJsonFile(paths.profile) || emptyProfile(id);
280
- const forgotten = readForgotten(dir, id);
281
-
282
- return {
283
- holderId: id,
284
- displayName: (row && row.displayName) || personaDisplayName(persona) || identity.DEFAULT_DISPLAY_NAME,
285
- ledger,
286
- persona,
287
- profile: profile && profile.holderId === id ? profile : { ...profile, holderId: id },
288
- forgotten,
289
- level: levelOf(ledger),
290
- scope: MIRROR_SESSIONS.map((kind) => mirrorSession(id, kind.split(':')[1])),
291
- };
292
- }
293
-
294
- function personaDisplayName(persona) {
295
- const name = persona && persona.identity && persona.identity.name;
296
- return typeof name === 'string' && name.trim() ? name.trim() : null;
297
- }
298
-
299
- /** Persist a folded profile into the holder's own cache file. */
300
- function writeProfile(dir, holderId, profile) {
301
- const withHolder = { ...(profile || {}), holderId };
302
- writeJsonFile(holderPaths(dir, holderId).profile, withHolder);
303
- return withHolder;
304
- }
305
-
306
- function writePersona(dir, holderId, persona) {
307
- writeJsonFile(holderPaths(dir, holderId).persona, { ...(persona || {}), holderId });
308
- return persona;
309
- }
310
-
311
- // -------------------------------------------------------------- switching --
312
-
313
- /**
314
- * Switch the active holder.
315
- *
316
- * The returned `state` is loaded *after* the registry write, so a caller cannot
317
- * accidentally keep rendering the old holder by holding a stale object: every
318
- * field that can carry holder A's text — ledger, persona, profile, forget list
319
- * and memory scope — is fresh. `survived` is the honest list of what came
320
- * across, and it is `['registry']` by construction.
321
- */
322
- function switchHolder(dir, holderId, opts = {}) {
323
- const id = safeHolderId(holderId);
324
- if (!id) return { ok: false, reason: 'expected a holder id (h_ + 8-16 hex)', survived: SURVIVES_SWITCH.slice() };
325
-
326
- const registry = opts.registry || readRegistry(dir);
327
- if (!holderRow(registry, id) && !opts.create) {
328
- return { ok: false, reason: `unknown holder ${id}`, survived: SURVIVES_SWITCH.slice() };
329
- }
330
-
331
- let next = registry;
332
- if (holderRow(registry, id)) {
333
- try {
334
- next = identity.setActive(registry, id);
335
- } catch (err) {
336
- next = { ...registry, active: id };
337
- }
338
- next = writeRegistry(dir, next);
339
- } else {
340
- const created = ensureHolder(dir, { id, displayName: opts.displayName, now: opts.now });
341
- next = writeRegistry(dir, identity.setActive(created.registry, id));
342
- }
343
-
344
- const state = loadState(dir, id, { registry: next });
345
- return {
346
- ok: true,
347
- previous: opts.previous || (registry && registry.active) || null,
348
- state,
349
- registry: next,
350
- survived: SURVIVES_SWITCH.slice(),
351
- };
352
- }
353
-
354
- /**
355
- * Resolve the active holder from signals (identity.resolve — one source of
356
- * truth for precedence) and, when a signal minted nothing, create the row so
357
- * the next launch resolves to the same holder.
358
- */
359
- function resolveActive(dir, signals = {}, opts = {}) {
360
- const registry = opts.registry || readRegistry(dir);
361
- const resolution = identity.resolve(signals, registry);
362
- if (resolution.needsMint || !resolution.holderId) {
363
- return { registry, resolution, holderId: null, created: false };
364
- }
365
- const existing = holderRow(registry, resolution.holderId);
366
- if (existing) {
367
- return { registry, resolution, holderId: existing.id, created: false };
368
- }
369
- const created = ensureHolder(dir, {
370
- id: resolution.holderId,
371
- displayName: resolution.displayName,
372
- fingerprint: resolution.proposal ? resolution.proposal.fingerprint : undefined,
373
- now: opts.now,
374
- registry,
375
- });
376
- return { registry: created.registry, resolution, holderId: created.row ? created.row.id : resolution.holderId, created: created.created };
377
- }
378
-
379
- /**
380
- * Apply the conflict card's choice (§2/§3).
381
- *
382
- * Default is *keep separate*: two holders stay two holders, and the only thing
383
- * the fingerprint gets is a note that it is shared. `rebind` requires
384
- * `confirm: true` — the explicit confirm behind the card — and still never
385
- * merges data: it moves the fingerprint onto the winner and records the loser
386
- * as unbound, because a fingerprint is not an identity.
387
- */
388
- function applyConflict(registry, resolution, choice = {}) {
389
- const reg = identity.load(registry || identity.emptyRegistry());
390
- const conflict = resolution && resolution.conflict;
391
- const target = (resolution && resolution.holderId) || reg.active;
392
- const action = choice && choice.choice === 'rebind' ? 'rebind' : 'keep-separate';
393
-
394
- if (!conflict || !target) {
395
- return { ok: false, action, reason: 'no conflict to resolve', registry: reg, requiresConfirm: false };
396
- }
397
-
398
- const other = conflict.a === target ? conflict.b : conflict.a;
399
- if (action === 'keep-separate') {
400
- // Nothing about the rows changes. Keeping the two is not a failure state —
401
- // it is the outcome the copy on the card recommends.
402
- return {
403
- ok: true,
404
- action,
405
- registry: reg,
406
- requiresConfirm: false,
407
- kept: [target, other].filter(Boolean),
408
- };
409
- }
410
-
411
- if (choice.confirm !== true) {
412
- return {
413
- ok: false,
414
- action,
415
- reason: 'rebind requires an explicit confirm',
416
- registry: reg,
417
- requiresConfirm: true,
418
- kept: [target, other].filter(Boolean),
419
- };
420
- }
421
-
422
- const losers = new Set((reg.holders || [])
423
- .filter((row) => row && row.id !== target && row.id === other)
424
- .map((row) => row.id));
425
- const holders = (reg.holders || []).map((row) => {
426
- if (!row) return row;
427
- if (row.id === target) {
428
- return { ...row, fingerprint: resolution.proposal ? resolution.proposal.fingerprint : row.fingerprint };
429
- }
430
- if (losers.has(row.id)) return { ...row, fingerprint: undefined };
431
- return row;
432
- });
433
-
434
- return {
435
- ok: true,
436
- action,
437
- requiresConfirm: false,
438
- registry: identity.load({ ...reg, holders }),
439
- rebound: { kept: target, unbound: Array.from(losers) },
440
- };
441
- }
442
-
443
- // ---------------------------------------------------------------- scope ----
444
-
445
- /**
446
- * The client-side filter (§3). `profile.belongsTo` is the same predicate the
447
- * fold uses, so there is one definition of "this row is this holder's", and a
448
- * row with no holder stamp is *unattributed* — it belongs to nobody in
449
- * particular and therefore to the holder asking, which is what makes a legacy
450
- * unstamped ledger readable instead of invisible.
451
- *
452
- * `clientFilter:false` exists only so the isolation test can prove the
453
- * filter is load-bearing by removing it and watching the leg go red. It is not
454
- * reachable from any IPC handler (holder-ipc.js never passes it).
455
- */
456
- function entriesForHolder(entries, holderId, opts = {}) {
457
- const rows = Array.isArray(entries) ? entries : [];
458
- const id = safeHolderId(holderId);
459
- const forgotten = opts.forgotten instanceof Set ? opts.forgotten : new Set();
460
- if (opts.clientFilter === false || !id) {
461
- return rows.filter((row) => row && typeof row === 'object' && !forgotten.has(String(rowId(row))));
462
- }
463
- return rows.filter((row) => {
464
- if (!row || typeof row !== 'object') return false;
465
- if (forgotten.has(String(rowId(row)))) return false;
466
- try {
467
- return profileModule.belongsTo(row, id);
468
- } catch (err) {
469
- return String(row.holder || '') === id;
470
- }
471
- });
472
- }
473
-
474
- function rowId(row) {
475
- if (!row || typeof row !== 'object') return '';
476
- return row.id != null ? row.id : (row.entryId != null ? row.entryId : '');
477
- }
478
-
479
- /**
480
- * Main stamps the holder on every row it writes; the renderer never supplies it
481
- * (same reasoning as lib/sync/persist-gate.js). A renderer-supplied `holder`
482
- * field is overwritten, not merged, so a malformed payload cannot smuggle a row
483
- * into another holder's fold.
484
- */
485
- function stampEntry(entry, holderId) {
486
- const id = safeHolderId(holderId);
487
- if (!entry || typeof entry !== 'object') return entry;
488
- if (!id) return { ...entry, holder: undefined };
489
- return { ...entry, holder: id };
490
- }
491
-
492
- // ------------------------------------------------------------ provenance ---
493
-
494
- /**
495
- * "Why does it know this" (§4): a facet's sources rendered as the underlying
496
- * entry text, in the facet's own source order.
497
- */
498
- function provenance(profile, entries, facetId, opts = {}) {
499
- const facets = (profile && Array.isArray(profile.facets)) ? profile.facets : [];
500
- const facet = facets.find((f) => f && f.id === facetId) || null;
501
- if (!facet) return { ok: false, reason: `no facet ${facetId}`, facet: null, claims: [] };
502
- const byId = new Map();
503
- for (const row of Array.isArray(entries) ? entries : []) {
504
- const id = rowId(row);
505
- if (id) byId.set(String(id), row);
506
- }
507
- const claims = (Array.isArray(facet.sources) ? facet.sources : []).map((sourceId) => {
508
- const row = byId.get(String(sourceId)) || null;
509
- return {
510
- entryId: String(sourceId),
511
- // The text is quoted as inert data: this surface renders memory, it never
512
- // re-emits it as an instruction (holder-turn.js neutralises on the way out).
513
- text: row ? textOf(row) : '',
514
- kind: row ? String(row.kind || '') : '',
515
- at: row && Number.isFinite(Number(row.t)) ? Number(row.t) : null,
516
- present: Boolean(row),
517
- };
518
- });
519
- return {
520
- ok: true,
521
- facet: { id: facet.id, key: facet.key, value: facet.value, confidence: facet.confidence },
522
- claims,
523
- missing: claims.filter((c) => !c.present).map((c) => c.entryId),
524
- };
525
- }
526
-
527
- function textOf(row) {
528
- if (!row || typeof row !== 'object') return '';
529
- const value = row.text != null ? row.text : (row.content != null ? row.content : row.value);
530
- return typeof value === 'string' ? value : '';
531
- }
532
-
533
- /**
534
- * Forget one facet: drop the facet from the profile and put the entries behind
535
- * it on the holder's forget list, which `entriesForHolder()` reads on the next
536
- * fold — so the claim cannot silently re-derive from the same row.
537
- *
538
- * @returns {{ ok:boolean, profile:object, removed:string[], facetIds:string[] }}
539
- */
540
- function forgetFacet(dir, holderId, profile, facetId) {
541
- const facets = (profile && Array.isArray(profile.facets)) ? profile.facets : [];
542
- const facet = facets.find((f) => f && f.id === facetId) || null;
543
- if (!facet) return { ok: false, reason: `no facet ${facetId}`, profile, removed: [], facetIds: [] };
544
-
545
- const removed = (Array.isArray(facet.sources) ? facet.sources : []).map(String);
546
- const forgotten = readForgotten(dir, holderId);
547
- for (const id of removed) forgotten.add(id);
548
- writeForgotten(dir, holderId, forgotten);
549
-
550
- // A second facet folded from the same entries loses its sources too: dropping
551
- // nothing but the clicked facet would leave "why does it know this" pointing
552
- // at entries the holder just deleted.
553
- const pruned = facets
554
- .filter((f) => f && f.id !== facet.id)
555
- .map((f) => {
556
- const sources = (Array.isArray(f.sources) ? f.sources : []).filter((id) => !forgotten.has(String(id)));
557
- return { ...f, sources };
558
- })
559
- .filter((f) => (Array.isArray(f.sources) ? f.sources.length : 0) > 0);
560
- const dropped = facets.filter((f) => f && f.id !== facet.id).length - pruned.length;
561
-
562
- const next = {
563
- ...(profile || {}),
564
- holderId,
565
- facets: pruned,
566
- budget: { tokens: (profile && profile.budget && profile.budget.tokens) || profileModule.BUDGET_TOKENS, used: 0 },
567
- coldStart: pruned.length === 0,
568
- };
569
- writeProfile(dir, holderId, next);
570
- return {
571
- ok: true,
572
- profile: next,
573
- removed,
574
- facetIds: [facet.id].concat(dropped > 0 ? pruned.map((f) => f.id) : []),
575
- };
576
- }
577
-
578
- module.exports = {
579
- AVATAR_DIR,
580
- REGISTRY_FILE,
581
- HOLDERS_DIR,
582
- LEDGER_FILE,
583
- PERSONA_FILE,
584
- PROFILE_FILE,
585
- FORGOTTEN_FILE,
586
- SURVIVES_SWITCH,
587
- MIRROR_SESSIONS,
588
- mirrorSession,
589
- registryPath,
590
- holderDir,
591
- holderPaths,
592
- readJsonFile,
593
- writeJsonFile,
594
- readLedger,
595
- appendLedgerRow,
596
- readRegistry,
597
- writeRegistry,
598
- holderRow,
599
- ensureHolder,
600
- levelOf,
601
- readForgotten,
602
- writeForgotten,
603
- emptyProfile,
604
- loadState,
605
- writeProfile,
606
- writePersona,
607
- switchHolder,
608
- resolveActive,
609
- applyConflict,
610
- entriesForHolder,
611
- stampEntry,
612
- provenance,
613
- forgetFacet,
614
- };
3
+ "use strict";const fs=require("node:fs"),path=require("node:path"),identity=require("./identity.js"),personaModule=require("./persona.js"),profileModule=require("./profile.js"),xp=require("./xp.js"),AVATAR_DIR="avatar",REGISTRY_FILE="holders.json",HOLDERS_DIR="holders",LEDGER_FILE="ledger.jsonl",PERSONA_FILE="persona.json",PROFILE_FILE="profile.json",FORGOTTEN_FILE="forgotten.json",SURVIVES_SWITCH=Object.freeze(["registry"]),MIRROR_SESSIONS=Object.freeze(["avatar:ledger","avatar:profile"]);function mirrorSession(e,r="ledger"){return`avatar:${"profile"===r?"profile":"ledger"}:${e}`}function registryPath(e){return path.join(String(e),AVATAR_DIR,REGISTRY_FILE)}function holderDir(e,r){const t=safeHolderId(r);if(!t)throw new Error("holderDir: expected a holder id (h_ + 8-16 hex)");return path.join(String(e),AVATAR_DIR,HOLDERS_DIR,t)}function holderPaths(e,r){const t=holderDir(e,r);return{dir:t,ledger:path.join(t,LEDGER_FILE),persona:path.join(t,PERSONA_FILE),profile:path.join(t,PROFILE_FILE),forgotten:path.join(t,FORGOTTEN_FILE)}}function safeHolderId(e){const r=null==e?"":String(e).trim();return r&&identity.HOLDER_ID_RE&&identity.HOLDER_ID_RE.test(r)?r:null}function readJsonFile(e){try{const r=fs.readFileSync(e,"utf8"),t=JSON.parse(r);return t&&"object"==typeof t?t:null}catch(e){return null}}function writeJsonFile(e,r){fs.mkdirSync(path.dirname(e),{recursive:!0,mode:448});const t=`${e}.tmp`;return fs.writeFileSync(t,`${JSON.stringify(r,null,2)}\n`),fs.renameSync(t,e),r}function readLedger(e){let r="";try{r=fs.readFileSync(e,"utf8")}catch(e){return[]}const t=[];for(const e of r.split("\n")){const r=e.trim();if(r)try{const e=JSON.parse(r);e&&"object"==typeof e&&t.push(e)}catch(e){}}return t}function appendLedgerRow(e,r){return fs.mkdirSync(path.dirname(e),{recursive:!0,mode:448}),fs.appendFileSync(e,`${JSON.stringify(r)}\n`),r}function readRegistry(e){const r=readJsonFile(registryPath(e));try{return identity.load(r||identity.emptyRegistry())}catch(e){return identity.load(identity.emptyRegistry())}}function writeRegistry(e,r){const t=identity.load(r);return writeJsonFile(registryPath(e),t),t}function holderRow(e,r){const t=safeHolderId(r);return t&&(e.holders||[]).find((e=>e&&e.id===t))||null}function ensureHolder(e,r={}){const t=r.registry||readRegistry(e),o=r.id?safeHolderId(r.id):null,n=o?holderRow(t,o):null;if(n)return{registry:t,row:n,created:!1};const i=identity.addHolder(t,{id:o||void 0,displayName:r.displayName,fingerprint:r.fingerprint,accountId:r.accountId,now:r.now}),s=identity.load(i),d=s.holders[s.holders.length-1]||null;return!1!==r.persist&&writeRegistry(e,s),{registry:s,row:d,created:!0}}function levelOf(e){try{const r=xp.evaluate(Array.isArray(e)?e:[]);return r&&Number.isFinite(r.level)?r.level:1}catch(e){return 1}}function readForgotten(e,r){const t=readJsonFile(holderPaths(e,r).forgotten),o=t&&Array.isArray(t.entries)?t.entries:[];return new Set(o.map(String))}function writeForgotten(e,r,t){const o=Array.from(new Set(Array.from(t).map(String))).sort();return writeJsonFile(holderPaths(e,r).forgotten,{schema:1,holderId:r,entries:o}),new Set(o)}function emptyProfile(e){return{schema:1,holderId:e,facets:[],budget:{tokens:0,used:0},coldStart:!0}}function loadState(e,r,t={}){const o=safeHolderId(r);if(!o)throw new Error("loadState: expected a holder id (h_ + 8-16 hex)");const n=holderRow(t.registry||readRegistry(e),o),i=holderPaths(e,o),s=readLedger(i.ledger),d=readJsonFile(i.persona);let l=personaModule.defaultPersona();if(d)try{const e=personaModule.load(d);l=e&&e.persona?e.persona:e}catch(e){l=personaModule.defaultPersona()}else l=personaModule.defaultPersona();const a=readJsonFile(i.profile)||emptyProfile(o),c=readForgotten(e,o);return{holderId:o,displayName:n&&n.displayName||personaDisplayName(l)||identity.DEFAULT_DISPLAY_NAME,ledger:s,persona:l,profile:a&&a.holderId===o?a:{...a,holderId:o},forgotten:c,level:levelOf(s),scope:MIRROR_SESSIONS.map((e=>mirrorSession(o,e.split(":")[1])))}}function personaDisplayName(e){const r=e&&e.identity&&e.identity.name;return"string"==typeof r&&r.trim()?r.trim():null}function writeProfile(e,r,t){const o={...t||{},holderId:r};return writeJsonFile(holderPaths(e,r).profile,o),o}function writePersona(e,r,t){return writeJsonFile(holderPaths(e,r).persona,{...t||{},holderId:r}),t}function switchHolder(e,r,t={}){const o=safeHolderId(r);if(!o)return{ok:!1,reason:"expected a holder id (h_ + 8-16 hex)",survived:SURVIVES_SWITCH.slice()};const n=t.registry||readRegistry(e);if(!holderRow(n,o)&&!t.create)return{ok:!1,reason:`unknown holder ${o}`,survived:SURVIVES_SWITCH.slice()};let i=n;if(holderRow(n,o)){try{i=identity.setActive(n,o)}catch(e){i={...n,active:o}}i=writeRegistry(e,i)}else{const r=ensureHolder(e,{id:o,displayName:t.displayName,now:t.now});i=writeRegistry(e,identity.setActive(r.registry,o))}const s=loadState(e,o,{registry:i});return{ok:!0,previous:t.previous||n&&n.active||null,state:s,registry:i,survived:SURVIVES_SWITCH.slice()}}function resolveActive(e,r={},t={}){const o=t.registry||readRegistry(e),n=identity.resolve(r,o);if(n.needsMint||!n.holderId)return{registry:o,resolution:n,holderId:null,created:!1};const i=holderRow(o,n.holderId);if(i)return{registry:o,resolution:n,holderId:i.id,created:!1};const s=ensureHolder(e,{id:n.holderId,displayName:n.displayName,fingerprint:n.proposal?n.proposal.fingerprint:void 0,now:t.now,registry:o});return{registry:s.registry,resolution:n,holderId:s.row?s.row.id:n.holderId,created:s.created}}function applyConflict(e,r,t={}){const o=identity.load(e||identity.emptyRegistry()),n=r&&r.conflict,i=r&&r.holderId||o.active,s=t&&"rebind"===t.choice?"rebind":"keep-separate";if(!n||!i)return{ok:!1,action:s,reason:"no conflict to resolve",registry:o,requiresConfirm:!1};const d=n.a===i?n.b:n.a;if("keep-separate"===s)return{ok:!0,action:s,registry:o,requiresConfirm:!1,kept:[i,d].filter(Boolean)};if(!0!==t.confirm)return{ok:!1,action:s,reason:"rebind requires an explicit confirm",registry:o,requiresConfirm:!0,kept:[i,d].filter(Boolean)};const l=new Set((o.holders||[]).filter((e=>e&&e.id!==i&&e.id===d)).map((e=>e.id))),a=(o.holders||[]).map((e=>e?e.id===i?{...e,fingerprint:r.proposal?r.proposal.fingerprint:e.fingerprint}:l.has(e.id)?{...e,fingerprint:void 0}:e:e));return{ok:!0,action:s,requiresConfirm:!1,registry:identity.load({...o,holders:a}),rebound:{kept:i,unbound:Array.from(l)}}}function entriesForHolder(e,r,t={}){const o=Array.isArray(e)?e:[],n=safeHolderId(r),i=t.forgotten instanceof Set?t.forgotten:new Set;return!1!==t.clientFilter&&n?o.filter((e=>{if(!e||"object"!=typeof e)return!1;if(i.has(String(rowId(e))))return!1;try{return profileModule.belongsTo(e,n)}catch(r){return String(e.holder||"")===n}})):o.filter((e=>e&&"object"==typeof e&&!i.has(String(rowId(e)))))}function rowId(e){return e&&"object"==typeof e?null!=e.id?e.id:null!=e.entryId?e.entryId:"":""}function stampEntry(e,r){const t=safeHolderId(r);return e&&"object"==typeof e?t?{...e,holder:t}:{...e,holder:void 0}:e}function provenance(e,r,t,o={}){const n=(e&&Array.isArray(e.facets)?e.facets:[]).find((e=>e&&e.id===t))||null;if(!n)return{ok:!1,reason:`no facet ${t}`,facet:null,claims:[]};const i=new Map;for(const e of Array.isArray(r)?r:[]){const r=rowId(e);r&&i.set(String(r),e)}const s=(Array.isArray(n.sources)?n.sources:[]).map((e=>{const r=i.get(String(e))||null;return{entryId:String(e),text:r?textOf(r):"",kind:r?String(r.kind||""):"",at:r&&Number.isFinite(Number(r.t))?Number(r.t):null,present:Boolean(r)}}));return{ok:!0,facet:{id:n.id,key:n.key,value:n.value,confidence:n.confidence},claims:s,missing:s.filter((e=>!e.present)).map((e=>e.entryId))}}function textOf(e){if(!e||"object"!=typeof e)return"";const r=null!=e.text?e.text:null!=e.content?e.content:e.value;return"string"==typeof r?r:""}function forgetFacet(e,r,t,o){const n=t&&Array.isArray(t.facets)?t.facets:[],i=n.find((e=>e&&e.id===o))||null;if(!i)return{ok:!1,reason:`no facet ${o}`,profile:t,removed:[],facetIds:[]};const s=(Array.isArray(i.sources)?i.sources:[]).map(String),d=readForgotten(e,r);for(const e of s)d.add(e);writeForgotten(e,r,d);const l=n.filter((e=>e&&e.id!==i.id)).map((e=>{const r=(Array.isArray(e.sources)?e.sources:[]).filter((e=>!d.has(String(e))));return{...e,sources:r}})).filter((e=>(Array.isArray(e.sources)?e.sources.length:0)>0)),a=n.filter((e=>e&&e.id!==i.id)).length-l.length,c={...t||{},holderId:r,facets:l,budget:{tokens:t&&t.budget&&t.budget.tokens||profileModule.BUDGET_TOKENS,used:0},coldStart:0===l.length};return writeProfile(e,r,c),{ok:!0,profile:c,removed:s,facetIds:[i.id].concat(a>0?l.map((e=>e.id)):[])}}module.exports={AVATAR_DIR:AVATAR_DIR,REGISTRY_FILE:REGISTRY_FILE,HOLDERS_DIR:HOLDERS_DIR,LEDGER_FILE:LEDGER_FILE,PERSONA_FILE:PERSONA_FILE,PROFILE_FILE:PROFILE_FILE,FORGOTTEN_FILE:FORGOTTEN_FILE,SURVIVES_SWITCH:SURVIVES_SWITCH,MIRROR_SESSIONS:MIRROR_SESSIONS,mirrorSession:mirrorSession,registryPath:registryPath,holderDir:holderDir,holderPaths:holderPaths,readJsonFile:readJsonFile,writeJsonFile:writeJsonFile,readLedger:readLedger,appendLedgerRow:appendLedgerRow,readRegistry:readRegistry,writeRegistry:writeRegistry,holderRow:holderRow,ensureHolder:ensureHolder,levelOf:levelOf,readForgotten:readForgotten,writeForgotten:writeForgotten,emptyProfile:emptyProfile,loadState:loadState,writeProfile:writeProfile,writePersona:writePersona,switchHolder:switchHolder,resolveActive:resolveActive,applyConflict:applyConflict,entriesForHolder:entriesForHolder,stampEntry:stampEntry,provenance:provenance,forgetFacet:forgetFacet};