@ifsoul/core 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,621 @@
1
+ import { SessionError } from "./participants.js";
2
+ import { canonical } from "../../protocol/src/index.js";
3
+ import { resolveResourceLimits } from "../../protocol/src/resource-policy.js";
4
+ const permissions = [
5
+ "observe",
6
+ "remember",
7
+ "updateRelationship",
8
+ "speak",
9
+ "requestAction",
10
+ ];
11
+ export const DEFAULT_BEHAVIOR_OPERATOR = Object.freeze({
12
+ canEditConfig: false,
13
+ lockedParameterIds: [],
14
+ deniedPermissions: [],
15
+ lockedPolicy: false,
16
+ lockedGoals: false,
17
+ });
18
+ export const DEFAULT_BEHAVIOR_LIMITS = Object.freeze({
19
+ parameters: 32,
20
+ overrides: 128,
21
+ values: 4096,
22
+ history: 8192,
23
+ observations: 4096,
24
+ proposals: 2048,
25
+ changesPerProposal: 8,
26
+ evidencePerChange: 16,
27
+ });
28
+ const failure = (code) => {
29
+ throw new SessionError(code);
30
+ };
31
+ const id = (value) => typeof value === "string" &&
32
+ /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(value);
33
+ const finite = (value) => typeof value === "number" &&
34
+ Number.isFinite(value) &&
35
+ Math.abs(value) <= Number.MAX_SAFE_INTEGER;
36
+ const obj = (value) => !!value && typeof value === "object" && !Array.isArray(value);
37
+ const keys = (value, allowed) => obj(value) && Object.keys(value).every((k) => allowed.includes(k));
38
+ const same = (a, b) => canonical(a) === canonical(b);
39
+ const unique = (values, maximum) => Array.isArray(values) &&
40
+ values.length <= maximum &&
41
+ values.every(id) &&
42
+ new Set(values).size === values.length;
43
+ const revision = (state, npcId) => Object.hasOwn(state.stateRevisions, npcId) ? state.stateRevisions[npcId] : 0;
44
+ export function defaultBehaviorConfiguration() {
45
+ const general = {
46
+ observe: true,
47
+ remember: true,
48
+ updateRelationship: true,
49
+ speak: true,
50
+ requestAction: true,
51
+ };
52
+ return {
53
+ version: "ifsoul-behavior/0.4.0",
54
+ revision: 0,
55
+ parameters: ["trust", "cooperation", "caution"].map((parameterId) => ({
56
+ parameterId,
57
+ label: parameterId[0].toUpperCase() + parameterId.slice(1),
58
+ kind: "relationship",
59
+ scope: "portable",
60
+ initial: 50,
61
+ min: 0,
62
+ max: 100,
63
+ maxDelta: 5,
64
+ })),
65
+ defaults: {
66
+ policy: {
67
+ general,
68
+ provocation: { ...general, speak: false, requestAction: false },
69
+ },
70
+ goals: [],
71
+ },
72
+ overrides: [],
73
+ };
74
+ }
75
+ const base = (s) => {
76
+ if (s.behavior && s.behavior.format !== "ifsoul-behavior/0.4.0")
77
+ return failure("invalid");
78
+ return s.behavior?.config ?? defaultBehaviorConfiguration();
79
+ };
80
+ const requireState = (s, create = false) => {
81
+ if (s.behavior) {
82
+ if (s.behavior.format !== "ifsoul-behavior/0.4.0")
83
+ return failure("invalid");
84
+ return s.behavior;
85
+ }
86
+ if (!create)
87
+ return failure("invalid");
88
+ return (s.behavior = {
89
+ format: "ifsoul-behavior/0.4.0",
90
+ config: defaultBehaviorConfiguration(),
91
+ observations: [],
92
+ values: [],
93
+ history: [],
94
+ proposals: [],
95
+ stateRevisions: {},
96
+ });
97
+ };
98
+ function sessionFor(s, a, sessionId, npcId) {
99
+ const session = s.sessions.find((x) => x.sessionId === sessionId && x.hostId === a.hostId);
100
+ if (!session ||
101
+ !session.members.some((m) => m.participantId === npcId && m.role === "npc"))
102
+ return failure("unauthorized");
103
+ return session;
104
+ }
105
+ /** World -> matching groups in lexicographic ID order -> NPC -> immutable operator denies. */
106
+ export function effectiveBehavior(s, access, npcId, sessionId, operator = DEFAULT_BEHAVIOR_OPERATOR) {
107
+ const session = sessionFor(s, access, sessionId, npcId);
108
+ const config = base(s), effective = structuredClone({ ...config.defaults, initialValues: {} });
109
+ const groups = session.groups
110
+ .filter((g) => g.participantIds.includes(npcId))
111
+ .map((g) => g.groupId)
112
+ .sort();
113
+ const scopes = [
114
+ { kind: "world", id: session.worldId },
115
+ ...groups.map((id) => ({ kind: "group", id })),
116
+ { kind: "npc", id: npcId },
117
+ ];
118
+ for (const scope of scopes) {
119
+ const override = config.overrides.find((x) => x.scope.kind === scope.kind && x.scope.id === scope.id);
120
+ if (!override)
121
+ continue;
122
+ for (const category of ["general", "provocation"])
123
+ Object.assign(effective.policy[category], override.policy?.[category]);
124
+ if (override.goals)
125
+ effective.goals = structuredClone(override.goals);
126
+ if (override.initialValues)
127
+ Object.assign(effective.initialValues, override.initialValues);
128
+ }
129
+ for (const flag of operator.deniedPermissions)
130
+ for (const category of ["general", "provocation"])
131
+ effective.policy[category][flag] = false;
132
+ return effective;
133
+ }
134
+ export function behaviorPermissions(s, access, npcId, sessionId, eventId, operator = DEFAULT_BEHAVIOR_OPERATOR) {
135
+ const effective = effectiveBehavior(s, access, npcId, sessionId, operator);
136
+ const kind = s.behavior?.observations.find((x) => x.npcId === npcId && x.eventId === eventId)?.kind;
137
+ return effective.policy[kind === "provocation" ? "provocation" : "general"];
138
+ }
139
+ export const behaviorRevision = (s) => s.behavior?.config.revision ?? 0;
140
+ /** Previously unobserved/unremembered evidence never becomes implicit model context. */
141
+ export function behaviorEvidenceVisible(s, access, npcId, sessionId, eventId, operator = DEFAULT_BEHAVIOR_OPERATOR) {
142
+ sessionFor(s, access, sessionId, npcId);
143
+ const event = s.events.find((e) => e.eventId === eventId);
144
+ if (!event ||
145
+ !s.sessions.some((source) => source.sessionId === event.sessionId && source.hostId === access.hostId))
146
+ return false;
147
+ if (!s.behavior)
148
+ return true;
149
+ const observation = s.behavior.observations.find((x) => x.npcId === npcId && x.eventId === eventId);
150
+ const current = behaviorPermissions(s, access, npcId, sessionId, eventId, operator);
151
+ return (!!observation &&
152
+ observation.permissions.observe &&
153
+ observation.permissions.remember &&
154
+ current.observe &&
155
+ current.remember);
156
+ }
157
+ function validGoals(value) {
158
+ return (Array.isArray(value) &&
159
+ value.length <= 16 &&
160
+ unique(value.map((g) => g?.goalId), 16) &&
161
+ value.every((g) => keys(g, ["goalId", "objective", "priority", "enabled"]) &&
162
+ typeof g.objective === "string" &&
163
+ g.objective.length > 0 &&
164
+ g.objective.length <= 512 &&
165
+ Number.isInteger(g.priority) &&
166
+ g.priority >= 0 &&
167
+ g.priority <= 100 &&
168
+ typeof g.enabled === "boolean"));
169
+ }
170
+ const flags = (value, partial = false) => keys(value, permissions) &&
171
+ Object.values(value).every((x) => typeof x === "boolean") &&
172
+ (partial || Object.keys(value).length === permissions.length);
173
+ const policy = (value, partial = false) => keys(value, ["general", "provocation"]) &&
174
+ (partial || Object.keys(value).length === 2) &&
175
+ Object.values(value).every((x) => flags(x, partial));
176
+ function definition(value) {
177
+ return (keys(value, [
178
+ "parameterId",
179
+ "label",
180
+ "kind",
181
+ "scope",
182
+ "initial",
183
+ "min",
184
+ "max",
185
+ "maxDelta",
186
+ "decay",
187
+ ]) &&
188
+ id(value.parameterId) &&
189
+ typeof value.label === "string" &&
190
+ value.label.length > 0 &&
191
+ value.label.length <= 80 &&
192
+ ["trait", "state", "relationship"].includes(value.kind) &&
193
+ ["portable", "world"].includes(value.scope) &&
194
+ [value.initial, value.min, value.max, value.maxDelta].every(finite) &&
195
+ value.min < value.max &&
196
+ value.initial >= value.min &&
197
+ value.initial <= value.max &&
198
+ value.maxDelta > 0 &&
199
+ value.maxDelta <= value.max - value.min &&
200
+ !(value.kind === "trait" && (value.scope !== "portable" || value.decay)) &&
201
+ (value.decay === undefined ||
202
+ (keys(value.decay, ["perHour", "toward"]) &&
203
+ finite(value.decay.perHour) &&
204
+ value.decay.perHour > 0 &&
205
+ finite(value.decay.toward) &&
206
+ value.decay.toward >= value.min &&
207
+ value.decay.toward <= value.max)));
208
+ }
209
+ const valueKey = (value) => JSON.stringify([
210
+ value.npcId,
211
+ value.parameterId,
212
+ value.targetParticipantId ?? null,
213
+ value.worldId ?? null,
214
+ ]);
215
+ export function createBehaviorRuntime(store, options = {}) {
216
+ const operator = structuredClone(options.operator ?? DEFAULT_BEHAVIOR_OPERATOR);
217
+ if (!keys(operator, [
218
+ "canEditConfig",
219
+ "lockedParameterIds",
220
+ "deniedPermissions",
221
+ "lockedPolicy",
222
+ "lockedGoals",
223
+ ]) ||
224
+ typeof operator.canEditConfig !== "boolean" ||
225
+ !unique(operator.lockedParameterIds, 128) ||
226
+ !Array.isArray(operator.deniedPermissions) ||
227
+ operator.deniedPermissions.some((x) => !permissions.includes(x)) ||
228
+ typeof operator.lockedPolicy !== "boolean" ||
229
+ typeof operator.lockedGoals !== "boolean")
230
+ throw new RangeError("Invalid behavior operator policy");
231
+ const report = resolveResourceLimits({
232
+ layer: "behavior",
233
+ defaults: { ...DEFAULT_BEHAVIOR_LIMITS },
234
+ legacyCeilings: { ...DEFAULT_BEHAVIOR_LIMITS },
235
+ requested: options.limits,
236
+ ceilings: options.ceilings,
237
+ });
238
+ if (report.status !== "accepted")
239
+ throw new RangeError("Unsupported behavior limit");
240
+ const limits = report.effective;
241
+ const run = (a, work) => {
242
+ if (!a || !id(a.principalId) || !id(a.hostId))
243
+ return failure("unauthorized");
244
+ return store.transact(a.principalId, (s) => {
245
+ if (s.format !== "ifsoul-participants/0.4.0" ||
246
+ (s.behavior && s.behavior.format !== "ifsoul-behavior/0.4.0"))
247
+ return failure("invalid");
248
+ return structuredClone(work(s));
249
+ });
250
+ };
251
+ const inspect = (s, a, sessionId, npcId) => {
252
+ const session = sessionFor(s, a, sessionId, npcId), b = s.behavior, config = base(s);
253
+ return {
254
+ npcId,
255
+ sessionId,
256
+ configRevision: config.revision,
257
+ stateRevision: b ? revision(b, npcId) : 0,
258
+ effective: effectiveBehavior(s, a, npcId, sessionId, operator),
259
+ parameters: config.parameters,
260
+ values: (b?.values ?? []).filter((v) => v.npcId === npcId && (!v.worldId || v.worldId === session.worldId)),
261
+ history: (b?.history ?? []).filter((v) => v.npcId === npcId && (!v.worldId || v.worldId === session.worldId)),
262
+ };
263
+ };
264
+ return {
265
+ read(a) {
266
+ if (!operator.canEditConfig)
267
+ return failure("unauthorized");
268
+ return run(a, (s) => ({
269
+ config: base(s),
270
+ operator,
271
+ catalog: {
272
+ npcs: s.participants
273
+ .filter((p) => p.kind === "npc" &&
274
+ s.sessions.some((session) => session.members.some((m) => m.participantId === p.participantId && m.role === "npc")))
275
+ .map((p) => ({ npcId: p.participantId, label: p.participantId })),
276
+ sessions: s.sessions.map((x) => ({
277
+ sessionId: x.sessionId,
278
+ worldId: x.worldId,
279
+ npcIds: x.members
280
+ .filter((m) => m.role === "npc")
281
+ .map((m) => m.participantId),
282
+ })),
283
+ groups: s.sessions.flatMap((x) => x.groups.map((g) => ({
284
+ groupId: g.groupId,
285
+ npcIds: g.participantIds.filter((id) => s.participants.some((p) => p.participantId === id && p.kind === "npc")),
286
+ }))),
287
+ worlds: [...new Set(s.sessions.map((x) => x.worldId))],
288
+ },
289
+ }));
290
+ },
291
+ update(a, expectedRevision, config) {
292
+ return run(a, (s) => {
293
+ if (!operator.canEditConfig)
294
+ return failure("unauthorized");
295
+ const prior = base(s);
296
+ if (!keys(config, [
297
+ "version",
298
+ "revision",
299
+ "parameters",
300
+ "defaults",
301
+ "overrides",
302
+ ]) ||
303
+ config.version !== "ifsoul-behavior/0.4.0" ||
304
+ !Number.isSafeInteger(expectedRevision) ||
305
+ expectedRevision < 0 ||
306
+ config.revision !== expectedRevision + 1 ||
307
+ !Array.isArray(config.parameters) ||
308
+ !config.parameters.length ||
309
+ !unique(config.parameters.map((p) => p?.parameterId), limits.parameters) ||
310
+ !config.parameters.every(definition) ||
311
+ !keys(config.defaults, ["policy", "goals"]) ||
312
+ !policy(config.defaults.policy) ||
313
+ !validGoals(config.defaults.goals) ||
314
+ !Array.isArray(config.overrides) ||
315
+ config.overrides.length > limits.overrides)
316
+ return failure("invalid");
317
+ if (prior.revision !== expectedRevision)
318
+ return failure("conflict");
319
+ const scopeKeys = new Set();
320
+ for (const o of config.overrides) {
321
+ if (!keys(o, ["scope", "policy", "goals", "initialValues"]) ||
322
+ !keys(o.scope, ["kind", "id"]) ||
323
+ !["npc", "group", "world"].includes(o.scope.kind) ||
324
+ !id(o.scope.id) ||
325
+ (o.policy !== undefined && !policy(o.policy, true)) ||
326
+ (o.goals !== undefined && !validGoals(o.goals)) ||
327
+ (o.initialValues !== undefined &&
328
+ (!obj(o.initialValues) ||
329
+ Object.entries(o.initialValues).some(([id, value]) => {
330
+ const d = config.parameters.find((p) => p.parameterId === id);
331
+ return !d || !finite(value) || value < d.min || value > d.max;
332
+ }))))
333
+ return failure("invalid");
334
+ const key = JSON.stringify(o.scope);
335
+ if (scopeKeys.has(key))
336
+ return failure("conflict");
337
+ scopeKeys.add(key);
338
+ if ((o.scope.kind === "npc" &&
339
+ !s.participants.some((p) => p.participantId === o.scope.id && p.kind === "npc")) ||
340
+ (o.scope.kind === "world" &&
341
+ !s.sessions.some((x) => x.worldId === o.scope.id)) ||
342
+ (o.scope.kind === "group" &&
343
+ !s.sessions.some((x) => x.groups.some((g) => g.groupId === o.scope.id))))
344
+ return failure("unauthorized");
345
+ }
346
+ for (const locked of operator.lockedParameterIds) {
347
+ if (!same(prior.parameters.find((p) => p.parameterId === locked), config.parameters.find((p) => p.parameterId === locked)) ||
348
+ !same(prior.overrides
349
+ .filter((o) => o.initialValues && Object.hasOwn(o.initialValues, locked))
350
+ .map((o) => [o.scope, o.initialValues?.[locked]]), config.overrides
351
+ .filter((o) => o.initialValues && Object.hasOwn(o.initialValues, locked))
352
+ .map((o) => [o.scope, o.initialValues?.[locked]])))
353
+ return failure("unauthorized");
354
+ }
355
+ if (operator.lockedPolicy &&
356
+ (!same(prior.defaults.policy, config.defaults.policy) ||
357
+ !same(prior.overrides
358
+ .filter((o) => o.policy !== undefined)
359
+ .map((o) => [o.scope, o.policy]), config.overrides
360
+ .filter((o) => o.policy !== undefined)
361
+ .map((o) => [o.scope, o.policy]))))
362
+ return failure("unauthorized");
363
+ if (operator.lockedGoals &&
364
+ (!same(prior.defaults.goals, config.defaults.goals) ||
365
+ !same(prior.overrides
366
+ .filter((o) => o.goals !== undefined)
367
+ .map((o) => [o.scope, o.goals]), config.overrides
368
+ .filter((o) => o.goals !== undefined)
369
+ .map((o) => [o.scope, o.goals]))))
370
+ return failure("unauthorized");
371
+ for (const flag of operator.deniedPermissions) {
372
+ const was = (p) => obj(p) && Object.values(p).some((v) => obj(v) && v[flag] === true);
373
+ if ((was(config.defaults.policy) && !was(prior.defaults.policy)) ||
374
+ config.overrides.some((o) => was(o.policy) &&
375
+ !prior.overrides.some((p) => same(p.scope, o.scope) && was(p.policy))))
376
+ return failure("unauthorized");
377
+ }
378
+ for (const old of prior.parameters) {
379
+ if (s.behavior?.values.some((v) => v.parameterId === old.parameterId) ||
380
+ s.behavior?.history.some((v) => v.parameterId === old.parameterId)) {
381
+ const next = config.parameters.find((p) => p.parameterId === old.parameterId);
382
+ if (!next || next.kind !== old.kind || next.scope !== old.scope)
383
+ return failure("invalid");
384
+ }
385
+ }
386
+ for (const v of s.behavior?.values ?? []) {
387
+ const d = config.parameters.find((p) => p.parameterId === v.parameterId);
388
+ if (d && (v.value < d.min || v.value > d.max))
389
+ return failure("invalid");
390
+ }
391
+ const b = requireState(s, true);
392
+ b.config = structuredClone(config);
393
+ return config;
394
+ });
395
+ },
396
+ adminInspect(a, sessionId, npcId) {
397
+ if (!operator.canEditConfig)
398
+ return failure("unauthorized");
399
+ return run(a, (s) => {
400
+ const session = s.sessions.find((x) => x.sessionId === sessionId);
401
+ if (!session)
402
+ return failure("unauthorized");
403
+ return inspect(s, { ...a, hostId: session.hostId }, sessionId, npcId);
404
+ });
405
+ },
406
+ inspect(a, sessionId, npcId) {
407
+ return run(a, (s) => inspect(s, a, sessionId, npcId));
408
+ },
409
+ observe(a, sessionId, npcId, eventId, kind) {
410
+ return run(a, (s) => {
411
+ sessionFor(s, a, sessionId, npcId);
412
+ if (!["provocation", "helpful", "observation"].includes(kind))
413
+ return failure("invalid");
414
+ const e = s.events.find((e) => e.eventId === eventId && e.sessionId === sessionId);
415
+ if (!e ||
416
+ ![e.senderId, ...e.recipientIds, ...e.observerIds].includes(npcId))
417
+ return failure("unauthorized");
418
+ const b = requireState(s, true), prior = b.observations.find((o) => o.eventId === eventId && o.npcId === npcId);
419
+ if (prior) {
420
+ if (prior.kind !== kind)
421
+ return failure("conflict");
422
+ return { observation: prior, fresh: false };
423
+ }
424
+ if (b.observations.length >= limits.observations)
425
+ return failure("limit");
426
+ const effective = effectiveBehavior(s, a, npcId, sessionId, operator)
427
+ .policy[kind === "provocation" ? "provocation" : "general"];
428
+ const observation = {
429
+ eventId,
430
+ npcId,
431
+ kind,
432
+ configRevision: b.config.revision,
433
+ permissions: effective,
434
+ };
435
+ b.observations.push(observation);
436
+ return { observation, fresh: true };
437
+ });
438
+ },
439
+ apply(a, proposal, nowMs) {
440
+ return run(a, (s) => {
441
+ if (!keys(proposal, [
442
+ "proposalId",
443
+ "npcId",
444
+ "sessionId",
445
+ "configRevision",
446
+ "expectedStateRevision",
447
+ "changes",
448
+ ]) ||
449
+ !id(proposal.proposalId) ||
450
+ !finite(nowMs) ||
451
+ nowMs < 0 ||
452
+ !Array.isArray(proposal.changes) ||
453
+ !proposal.changes.length ||
454
+ proposal.changes.length > limits.changesPerProposal)
455
+ return failure("invalid");
456
+ const session = sessionFor(s, a, proposal.sessionId, proposal.npcId), b = requireState(s, true);
457
+ const prior = b.proposals.find((p) => p.proposal.proposalId === proposal.proposalId);
458
+ if (prior) {
459
+ if (!same(prior.proposal, proposal))
460
+ return failure("conflict");
461
+ return inspect(s, a, proposal.sessionId, proposal.npcId);
462
+ }
463
+ if (b.config.revision !== proposal.configRevision ||
464
+ revision(b, proposal.npcId) !== proposal.expectedStateRevision)
465
+ return failure("stale");
466
+ if (b.proposals.length >= limits.proposals ||
467
+ b.history.length + proposal.changes.length > limits.history)
468
+ return failure("limit");
469
+ const nextRevision = proposal.expectedStateRevision + 1, changedKeys = new Set();
470
+ for (const change of proposal.changes) {
471
+ if (!keys(change, [
472
+ "parameterId",
473
+ "targetParticipantId",
474
+ "delta",
475
+ "confidence",
476
+ "evidenceEventIds",
477
+ "reason",
478
+ ]) ||
479
+ !id(change.parameterId) ||
480
+ !finite(change.delta) ||
481
+ !finite(change.confidence) ||
482
+ change.confidence < 0 ||
483
+ change.confidence > 1 ||
484
+ !unique(change.evidenceEventIds, limits.evidencePerChange) ||
485
+ !change.evidenceEventIds.length ||
486
+ typeof change.reason !== "string" ||
487
+ !change.reason.length ||
488
+ change.reason.length > 512)
489
+ return failure("invalid");
490
+ const d = b.config.parameters.find((p) => p.parameterId === change.parameterId);
491
+ if (!d || d.kind === "trait" || Math.abs(change.delta) > d.maxDelta)
492
+ return failure("invalid");
493
+ if ((d.kind === "relationship" &&
494
+ (!id(change.targetParticipantId) ||
495
+ change.targetParticipantId === proposal.npcId ||
496
+ !s.participants.some((p) => p.participantId === change.targetParticipantId))) ||
497
+ (d.kind !== "relationship" &&
498
+ change.targetParticipantId !== undefined))
499
+ return failure("unauthorized");
500
+ for (const eventId of change.evidenceEventIds) {
501
+ const e = s.events.find((e) => e.eventId === eventId), sourceSession = e && s.sessions.find((x) => x.sessionId === e.sessionId), observed = b.observations.find((o) => o.eventId === eventId && o.npcId === proposal.npcId);
502
+ if (!e ||
503
+ !sourceSession ||
504
+ sourceSession.hostId !== a.hostId ||
505
+ !session.recallWorldIds.includes(sourceSession.worldId) ||
506
+ (d.scope === "world" &&
507
+ sourceSession.worldId !== session.worldId) ||
508
+ !observed?.permissions.observe ||
509
+ ![e.senderId, ...e.recipientIds, ...e.observerIds].includes(proposal.npcId))
510
+ return failure("unauthorized");
511
+ const current = behaviorPermissions(s, a, proposal.npcId, proposal.sessionId, eventId, operator);
512
+ if (!current.observe ||
513
+ (d.kind === "relationship" &&
514
+ (!current.updateRelationship ||
515
+ !observed.permissions.updateRelationship)))
516
+ return failure("unauthorized");
517
+ if (d.kind === "relationship" &&
518
+ !(e.senderId === change.targetParticipantId ||
519
+ (e.senderId === proposal.npcId &&
520
+ e.recipientIds.includes(change.targetParticipantId))))
521
+ return failure("unauthorized");
522
+ }
523
+ const keyData = {
524
+ npcId: proposal.npcId,
525
+ parameterId: d.parameterId,
526
+ ...(change.targetParticipantId
527
+ ? { targetParticipantId: change.targetParticipantId }
528
+ : {}),
529
+ ...(d.scope === "world" ? { worldId: session.worldId } : {}),
530
+ }, key = valueKey(keyData);
531
+ if (changedKeys.has(key) ||
532
+ b.history.some((h) => valueKey(h) === key &&
533
+ h.evidenceEventIds.some((e) => change.evidenceEventIds.includes(e))))
534
+ return failure("conflict");
535
+ changedKeys.add(key);
536
+ const initialValues = effectiveBehavior(s, a, proposal.npcId, proposal.sessionId, operator).initialValues;
537
+ const old = b.values.find((v) => valueKey(v) === key), initial = Object.hasOwn(initialValues, d.parameterId)
538
+ ? initialValues[d.parameterId]
539
+ : d.initial, before = old?.value ?? initial, after = Number((before + change.delta).toFixed(6));
540
+ if (after < d.min || after > d.max || (old && nowMs < old.updatedAt))
541
+ return failure("invalid");
542
+ const value = {
543
+ ...keyData,
544
+ value: after,
545
+ confidence: change.confidence,
546
+ revision: nextRevision,
547
+ updatedAt: nowMs,
548
+ };
549
+ if (old)
550
+ b.values[b.values.indexOf(old)] = value;
551
+ else {
552
+ if (b.values.length >= limits.values)
553
+ return failure("limit");
554
+ b.values.push(value);
555
+ }
556
+ b.history.push({
557
+ ...keyData,
558
+ changeId: `${proposal.proposalId}:${d.parameterId}`,
559
+ before,
560
+ after,
561
+ confidence: change.confidence,
562
+ revision: nextRevision,
563
+ configRevision: b.config.revision,
564
+ evidenceEventIds: [...change.evidenceEventIds],
565
+ reason: change.reason,
566
+ kind: "evidence",
567
+ recordedAt: nowMs,
568
+ });
569
+ }
570
+ b.stateRevisions[proposal.npcId] = nextRevision;
571
+ b.proposals.push({
572
+ proposal: structuredClone(proposal),
573
+ revision: nextRevision,
574
+ });
575
+ return inspect(s, a, proposal.sessionId, proposal.npcId);
576
+ });
577
+ },
578
+ decay(a, sessionId, npcId, nowMs) {
579
+ return run(a, (s) => {
580
+ const session = sessionFor(s, a, sessionId, npcId), b = requireState(s, true);
581
+ if (!finite(nowMs) || nowMs < 0)
582
+ return failure("invalid");
583
+ let changed = false;
584
+ const nextRevision = revision(b, npcId) + 1;
585
+ for (const v of b.values.filter((v) => v.npcId === npcId && (!v.worldId || v.worldId === session.worldId))) {
586
+ const d = b.config.parameters.find((p) => p.parameterId === v.parameterId);
587
+ if (!d?.decay || d.kind === "trait")
588
+ continue;
589
+ if (nowMs < v.updatedAt)
590
+ return failure("stale");
591
+ const step = Math.min(d.maxDelta, (d.decay.perHour * (nowMs - v.updatedAt)) / 3600000, Math.abs(d.decay.toward - v.value));
592
+ if (step <= 0)
593
+ continue;
594
+ if (b.history.length >= limits.history)
595
+ return failure("limit");
596
+ const before = v.value, after = Number((before + Math.sign(d.decay.toward - before) * step).toFixed(6));
597
+ if (after === before)
598
+ continue;
599
+ v.value = after;
600
+ v.revision = nextRevision;
601
+ v.updatedAt = nowMs;
602
+ b.history.push({
603
+ ...v,
604
+ changeId: `decay:${npcId}:${b.history.length + 1}`,
605
+ before,
606
+ after,
607
+ configRevision: b.config.revision,
608
+ evidenceEventIds: [],
609
+ reason: "Configured explicit-time decay",
610
+ kind: "decay",
611
+ recordedAt: nowMs,
612
+ });
613
+ changed = true;
614
+ }
615
+ if (changed)
616
+ b.stateRevisions[npcId] = nextRevision;
617
+ return inspect(s, a, sessionId, npcId);
618
+ });
619
+ },
620
+ };
621
+ }