@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,107 @@
1
+ import type { BehaviorOperatorPolicy } from "./behavior-types.js";
2
+ import type { BehaviorState } from "./behavior-types.js";
3
+ import type { PlanningState } from "./group-scheduler.js";
4
+ /** Trusted session domain. Roots authenticate callers before constructing this access. */
5
+ export interface SessionAccess {
6
+ principalId: string;
7
+ hostId: string;
8
+ }
9
+ export interface SessionParticipant {
10
+ participantId: string;
11
+ kind: "npc" | "player";
12
+ /** Trusted host subject, never a display name or imported character identifier. */
13
+ subjectId: string;
14
+ }
15
+ export interface SessionMember {
16
+ participantId: string;
17
+ role: "npc" | "player";
18
+ embodimentId?: string;
19
+ }
20
+ export interface ParticipantSession {
21
+ sessionId: string;
22
+ revision: number;
23
+ hostId: string;
24
+ worldId: string;
25
+ recallWorldIds: string[];
26
+ members: SessionMember[];
27
+ groups: {
28
+ groupId: string;
29
+ participantIds: string[];
30
+ }[];
31
+ channels: {
32
+ channelId: string;
33
+ participantIds: string[];
34
+ }[];
35
+ }
36
+ export interface AddressedEvent {
37
+ eventId: string;
38
+ sessionId: string;
39
+ revision: number;
40
+ channelId: string;
41
+ senderId: string;
42
+ recipientIds: string[];
43
+ observerIds: string[];
44
+ text: string;
45
+ }
46
+ export interface SessionTurn {
47
+ turnId: string;
48
+ sessionId: string;
49
+ revision: number;
50
+ npcId: string;
51
+ eventId: string;
52
+ recipientIds: string[];
53
+ observerIds: string[];
54
+ }
55
+ export interface ParticipantState {
56
+ format: "ifsoul-participants/0.4.0";
57
+ planning?: PlanningState;
58
+ behavior?: BehaviorState;
59
+ participants: SessionParticipant[];
60
+ sessions: ParticipantSession[];
61
+ events: AddressedEvent[];
62
+ transitions: {
63
+ transitionId: string;
64
+ session: ParticipantSession;
65
+ }[];
66
+ turns: {
67
+ ticket: SessionTurn;
68
+ output?: AddressedEvent;
69
+ cancelled?: boolean;
70
+ behaviorRevision?: number;
71
+ }[];
72
+ }
73
+ /** Must atomically commit the draft only when work succeeds; namespace by verified principal. */
74
+ export interface ParticipantStore {
75
+ transact<T>(principalId: string, work: (state: ParticipantState) => T): T;
76
+ }
77
+ export declare const emptyParticipantState: () => ParticipantState;
78
+ export declare class SessionError extends Error {
79
+ readonly code: "unauthorized" | "invalid" | "stale" | "conflict" | "busy" | "limit";
80
+ constructor(code: "unauthorized" | "invalid" | "stale" | "conflict" | "busy" | "limit");
81
+ }
82
+ /** No account system, model, transport or host mechanics are constructed here. */
83
+ export declare function createParticipantRuntime(store: ParticipantStore, options?: {
84
+ behaviorOperator?: BehaviorOperatorPolicy;
85
+ }): {
86
+ /** Only trusted verified host mappings may call this; imported IDs never register themselves. */
87
+ register(access: SessionAccess, participant: SessionParticipant): SessionParticipant;
88
+ /** Full explicit replacement is a trusted transition, including leave/join/group/channel/host handoff. */
89
+ transition(access: SessionAccess, transitionId: string, expectedRevision: number, next: ParticipantSession): ParticipantSession;
90
+ append(access: SessionAccess, event: AddressedEvent): AddressedEvent;
91
+ /** Owner-private history; never use a group log as a model context. */
92
+ history(access: SessionAccess, sessionId: string, participantId: string): AddressedEvent[];
93
+ /** Source-grounded relationship evidence, keyed by verified persistent player ID. */
94
+ relationshipEvidence(access: SessionAccess, sessionId: string, npcId: string, playerId: string): {
95
+ npcId: string;
96
+ playerId: string;
97
+ events: AddressedEvent[];
98
+ };
99
+ begin(access: SessionAccess, ticket: SessionTurn): {
100
+ ticket: SessionTurn;
101
+ trigger: AddressedEvent;
102
+ output: AddressedEvent | undefined;
103
+ memories: AddressedEvent[];
104
+ };
105
+ cancel(access: SessionAccess, ticket: SessionTurn): undefined;
106
+ complete(access: SessionAccess, ticket: SessionTurn, output: AddressedEvent): AddressedEvent;
107
+ };
@@ -0,0 +1,309 @@
1
+ import { behaviorPermissions, behaviorRevision, behaviorEvidenceVisible, } from "./behavior.js";
2
+ export const emptyParticipantState = () => ({
3
+ format: "ifsoul-participants/0.4.0",
4
+ participants: [],
5
+ sessions: [],
6
+ events: [],
7
+ transitions: [],
8
+ turns: [],
9
+ });
10
+ export class SessionError extends Error {
11
+ code;
12
+ constructor(code) {
13
+ super(`Session operation rejected: ${code}`);
14
+ this.code = code;
15
+ }
16
+ }
17
+ const fail = (code) => {
18
+ throw new SessionError(code);
19
+ };
20
+ const id = (x) => typeof x === "string" && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(x);
21
+ const ids = (x) => Array.isArray(x) &&
22
+ x.length <= 64 &&
23
+ x.every(id) &&
24
+ new Set(x).size === x.length;
25
+ const equal = (a, b) => JSON.stringify(a) === JSON.stringify(b);
26
+ const audience = (e) => [
27
+ e.senderId,
28
+ ...e.recipientIds,
29
+ ...e.observerIds,
30
+ ];
31
+ /** No account system, model, transport or host mechanics are constructed here. */
32
+ export function createParticipantRuntime(store, options = {}) {
33
+ const behaviorOperator = options.behaviorOperator
34
+ ? structuredClone(options.behaviorOperator)
35
+ : undefined;
36
+ const run = (access, work) => {
37
+ if (!access || !id(access.principalId) || !id(access.hostId))
38
+ fail("unauthorized");
39
+ return store.transact(access.principalId, (state) => {
40
+ if (state.format !== "ifsoul-participants/0.4.0")
41
+ fail("invalid");
42
+ return structuredClone(work(state));
43
+ });
44
+ };
45
+ const session = (s, a, sessionId, revision) => {
46
+ const found = s.sessions.find((x) => x.sessionId === sessionId);
47
+ if (!found || found.hostId !== a.hostId)
48
+ return fail("unauthorized");
49
+ if (revision !== undefined && found.revision !== revision)
50
+ fail("stale");
51
+ return found;
52
+ };
53
+ const eventValid = (s, a, e) => {
54
+ if (!e ||
55
+ !id(e.eventId) ||
56
+ !id(e.senderId) ||
57
+ !id(e.channelId) ||
58
+ !ids(e.recipientIds) ||
59
+ !ids(e.observerIds) ||
60
+ typeof e.text !== "string" ||
61
+ !e.text.length ||
62
+ e.text.length > 4096)
63
+ fail("invalid");
64
+ const current = session(s, a, e.sessionId, e.revision);
65
+ const channel = current.channels.find((x) => x.channelId === e.channelId);
66
+ if (!channel ||
67
+ audience(e).some((p) => !channel.participantIds.includes(p) ||
68
+ !current.members.some((m) => m.participantId === p)))
69
+ fail("unauthorized");
70
+ return current;
71
+ };
72
+ return {
73
+ /** Only trusted verified host mappings may call this; imported IDs never register themselves. */
74
+ register(access, participant) {
75
+ return run(access, (s) => {
76
+ if (!participant ||
77
+ !id(participant.participantId) ||
78
+ !id(participant.subjectId) ||
79
+ !["npc", "player"].includes(participant.kind))
80
+ fail("invalid");
81
+ const previous = s.participants.find((p) => p.participantId === participant.participantId ||
82
+ (p.subjectId === participant.subjectId &&
83
+ p.kind === participant.kind));
84
+ if (previous && !equal(previous, participant))
85
+ fail("conflict");
86
+ if (!previous) {
87
+ if (s.participants.length >= 256)
88
+ fail("limit");
89
+ s.participants.push(structuredClone(participant));
90
+ }
91
+ return participant;
92
+ });
93
+ },
94
+ /** Full explicit replacement is a trusted transition, including leave/join/group/channel/host handoff. */
95
+ transition(access, transitionId, expectedRevision, next) {
96
+ return run(access, (s) => {
97
+ if (!id(transitionId) ||
98
+ !next ||
99
+ !id(next.sessionId) ||
100
+ !id(next.hostId) ||
101
+ !id(next.worldId) ||
102
+ !ids(next.recallWorldIds) ||
103
+ !next.recallWorldIds.includes(next.worldId) ||
104
+ !Number.isSafeInteger(expectedRevision) ||
105
+ expectedRevision < 0 ||
106
+ next.revision !== expectedRevision + 1)
107
+ fail("invalid");
108
+ const old = s.sessions.find((x) => x.sessionId === next.sessionId);
109
+ if ((old && old.hostId !== access.hostId) ||
110
+ (!old && next.hostId !== access.hostId))
111
+ fail("unauthorized");
112
+ const receipt = s.transitions.find((x) => x.transitionId === transitionId);
113
+ if (receipt) {
114
+ if (!equal(receipt.session, next))
115
+ fail("conflict");
116
+ return receipt.session;
117
+ }
118
+ if ((old?.revision ?? 0) !== expectedRevision)
119
+ fail("stale");
120
+ if (old && old.worldId !== next.worldId)
121
+ fail("conflict");
122
+ if (!Array.isArray(next.members) ||
123
+ next.members.length > 64 ||
124
+ !ids(next.members.map((m) => m.participantId)) ||
125
+ next.members.some((m) => !s.participants.some((p) => p.participantId === m.participantId && p.kind === m.role) ||
126
+ (m.embodimentId !== undefined && !id(m.embodimentId))))
127
+ fail("invalid");
128
+ for (const [list, key] of [
129
+ [next.groups, "groupId"],
130
+ [next.channels, "channelId"],
131
+ ]) {
132
+ if (!Array.isArray(list) ||
133
+ list.length > 64 ||
134
+ !ids(list.map((x) => x[key])))
135
+ fail("invalid");
136
+ if (list.some((x) => !ids(x.participantIds) ||
137
+ x.participantIds.some((p) => !next.members.some((m) => m.participantId === p))))
138
+ fail("unauthorized");
139
+ }
140
+ if (s.transitions.length >= 2048 || (!old && s.sessions.length >= 128))
141
+ fail("limit");
142
+ if (old)
143
+ s.sessions[s.sessions.indexOf(old)] = structuredClone(next);
144
+ else
145
+ s.sessions.push(structuredClone(next));
146
+ s.transitions.push({ transitionId, session: structuredClone(next) });
147
+ return next;
148
+ });
149
+ },
150
+ append(access, event) {
151
+ return run(access, (s) => {
152
+ eventValid(s, access, event);
153
+ const prior = s.events.find((e) => e.eventId === event.eventId);
154
+ if (prior) {
155
+ if (!equal(prior, event))
156
+ fail("conflict");
157
+ return prior;
158
+ }
159
+ if (s.events.length >= 2048)
160
+ fail("limit");
161
+ s.events.push(structuredClone(event));
162
+ return event;
163
+ });
164
+ },
165
+ /** Owner-private history; never use a group log as a model context. */
166
+ history(access, sessionId, participantId) {
167
+ return run(access, (s) => {
168
+ const current = session(s, access, sessionId);
169
+ if (!current.members.some((m) => m.participantId === participantId))
170
+ fail("unauthorized");
171
+ return s.events.filter((e) => e.sessionId === sessionId &&
172
+ audience(e).includes(participantId) &&
173
+ (current.members.find((m) => m.participantId === participantId)
174
+ ?.role !== "npc" ||
175
+ behaviorEvidenceVisible(s, access, participantId, sessionId, e.eventId)));
176
+ });
177
+ },
178
+ /** Source-grounded relationship evidence, keyed by verified persistent player ID. */
179
+ relationshipEvidence(access, sessionId, npcId, playerId) {
180
+ return run(access, (s) => {
181
+ const current = session(s, access, sessionId);
182
+ if (!current.members.some((m) => m.participantId === npcId && m.role === "npc") ||
183
+ !s.participants.some((p) => p.participantId === playerId && p.kind === "player"))
184
+ return fail("unauthorized");
185
+ return {
186
+ npcId,
187
+ playerId,
188
+ events: s.events.filter((e) => audience(e).includes(npcId) &&
189
+ audience(e).includes(playerId) &&
190
+ s.sessions.some((source) => source.sessionId === e.sessionId &&
191
+ source.hostId === access.hostId &&
192
+ current.recallWorldIds.includes(source.worldId))),
193
+ };
194
+ });
195
+ },
196
+ begin(access, ticket) {
197
+ return run(access, (s) => {
198
+ if (!ticket ||
199
+ !id(ticket.turnId) ||
200
+ !id(ticket.npcId) ||
201
+ !ids(ticket.recipientIds) ||
202
+ !ids(ticket.observerIds))
203
+ fail("invalid");
204
+ const current = session(s, access, ticket.sessionId, ticket.revision);
205
+ if (!current.members.some((m) => m.participantId === ticket.npcId && m.role === "npc"))
206
+ fail("unauthorized");
207
+ const trigger = s.events.find((e) => e.eventId === ticket.eventId &&
208
+ e.sessionId === ticket.sessionId &&
209
+ e.revision === ticket.revision);
210
+ if (!trigger ||
211
+ !audience(trigger).includes(ticket.npcId) ||
212
+ [...ticket.recipientIds, ...ticket.observerIds].some((p) => !audience(trigger).includes(p)))
213
+ return fail("unauthorized");
214
+ const policy = behaviorPermissions(s, access, ticket.npcId, ticket.sessionId, ticket.eventId, behaviorOperator);
215
+ if (!policy.observe ||
216
+ !policy.speak ||
217
+ (s.behavior &&
218
+ !s.behavior.observations.some((o) => o.npcId === ticket.npcId &&
219
+ o.eventId === ticket.eventId &&
220
+ o.permissions.observe)))
221
+ return fail("unauthorized");
222
+ const replay = s.turns.find((t) => t.ticket.turnId === ticket.turnId);
223
+ if (replay) {
224
+ if (!equal(replay.ticket, ticket) || !replay.output)
225
+ return fail("conflict");
226
+ return {
227
+ ticket,
228
+ trigger,
229
+ memories: [],
230
+ output: replay.output,
231
+ };
232
+ }
233
+ if (s.turns.some((t) => t.ticket.npcId === ticket.npcId &&
234
+ !t.output &&
235
+ !t.cancelled &&
236
+ s.sessions.some((x) => x.sessionId === t.ticket.sessionId &&
237
+ x.revision === t.ticket.revision)))
238
+ fail("busy");
239
+ if (s.turns.length >= 2048)
240
+ fail("limit");
241
+ s.turns.push({
242
+ ticket: structuredClone(ticket),
243
+ behaviorRevision: behaviorRevision(s),
244
+ });
245
+ // Recall is NPC-private, with stable player identity across worlds. Sharing is checked at completion.
246
+ const memories = s.events
247
+ .filter((e) => s.sessions.some((source) => source.sessionId === e.sessionId &&
248
+ source.hostId === access.hostId &&
249
+ current.recallWorldIds.includes(source.worldId)) &&
250
+ behaviorEvidenceVisible(s, access, ticket.npcId, ticket.sessionId, e.eventId) &&
251
+ [
252
+ ticket.npcId,
253
+ ...ticket.recipientIds,
254
+ ...ticket.observerIds,
255
+ ].every((p) => audience(e).includes(p)))
256
+ .slice(-64);
257
+ if (new TextEncoder().encode(JSON.stringify({ ticket, trigger, memories })).length > 65536)
258
+ return fail("limit");
259
+ return {
260
+ ticket,
261
+ trigger,
262
+ output: undefined,
263
+ memories,
264
+ };
265
+ });
266
+ },
267
+ cancel(access, ticket) {
268
+ return run(access, (s) => {
269
+ session(s, access, ticket.sessionId, ticket.revision);
270
+ const pending = s.turns.find((t) => t.ticket.turnId === ticket.turnId);
271
+ if (!pending || !equal(pending.ticket, ticket) || pending.output)
272
+ return fail("conflict");
273
+ pending.cancelled = true;
274
+ });
275
+ },
276
+ complete(access, ticket, output) {
277
+ return run(access, (s) => {
278
+ const pending = s.turns.find((t) => t.ticket.turnId === ticket.turnId);
279
+ if (!pending || pending.cancelled || !equal(pending.ticket, ticket))
280
+ return fail("unauthorized");
281
+ session(s, access, ticket.sessionId, ticket.revision);
282
+ if ((pending.behaviorRevision ?? 0) !== behaviorRevision(s) ||
283
+ !behaviorPermissions(s, access, ticket.npcId, ticket.sessionId, ticket.eventId, behaviorOperator).speak)
284
+ return fail("stale");
285
+ if (pending.output) {
286
+ if (!equal(pending.output, output))
287
+ fail("conflict");
288
+ return pending.output;
289
+ }
290
+ eventValid(s, access, output);
291
+ if (output.sessionId !== ticket.sessionId ||
292
+ output.revision !== ticket.revision ||
293
+ output.senderId !== ticket.npcId ||
294
+ output.channelId !==
295
+ s.events.find((e) => e.eventId === ticket.eventId)?.channelId ||
296
+ !equal(output.recipientIds, ticket.recipientIds) ||
297
+ !equal(output.observerIds, ticket.observerIds))
298
+ fail("unauthorized");
299
+ if (s.events.some((e) => e.eventId === output.eventId))
300
+ fail("conflict");
301
+ if (s.events.length >= 2048)
302
+ fail("limit");
303
+ s.events.push(structuredClone(output));
304
+ pending.output = structuredClone(output);
305
+ return output;
306
+ });
307
+ },
308
+ };
309
+ }
@@ -18,6 +18,7 @@ export interface TrustedSession {
18
18
  access: TrustedAccess;
19
19
  }
20
20
  export interface ControllerInput {
21
+ behavior?: Pick<import("./behavior-types.js").BehaviorInspection, "configRevision" | "effective" | "parameters" | "values">;
21
22
  portrayal?: {
22
23
  characterId: string;
23
24
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ifsoul/core",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Experimental public IfSoul protocol, runtime, memory and provider primitives",
package/release.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.4.0",
2
+ "version": "0.5.0",
3
3
  "current": {
4
4
  "protocol": "0.4.0",
5
5
  "modelProvider": "0.4.0",