@xfxstudio/claworld 0.1.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.
Files changed (69) hide show
  1. package/README.md +60 -0
  2. package/bin/claworld.mjs +9 -0
  3. package/index.js +51 -0
  4. package/openclaw.plugin.json +470 -0
  5. package/package.json +76 -0
  6. package/setup-entry.js +6 -0
  7. package/src/lib/accepted-chat-kickoff.js +192 -0
  8. package/src/lib/agent-address.js +46 -0
  9. package/src/lib/agent-profile.js +69 -0
  10. package/src/lib/http-auth.js +151 -0
  11. package/src/lib/policy.js +118 -0
  12. package/src/lib/runtime-errors.js +149 -0
  13. package/src/lib/runtime-guidance.js +458 -0
  14. package/src/openclaw/index.js +53 -0
  15. package/src/openclaw/installer/cli.js +349 -0
  16. package/src/openclaw/installer/constants.js +6 -0
  17. package/src/openclaw/installer/core.js +1548 -0
  18. package/src/openclaw/installer/doctor.js +690 -0
  19. package/src/openclaw/installer/workspace-contract.js +403 -0
  20. package/src/openclaw/plugin/account-identity.js +66 -0
  21. package/src/openclaw/plugin/claworld-channel-plugin.js +3118 -0
  22. package/src/openclaw/plugin/config-schema.js +464 -0
  23. package/src/openclaw/plugin/lifecycle.js +114 -0
  24. package/src/openclaw/plugin/managed-config.js +648 -0
  25. package/src/openclaw/plugin/onboarding.js +291 -0
  26. package/src/openclaw/plugin/register.js +961 -0
  27. package/src/openclaw/plugin/relay-client.js +783 -0
  28. package/src/openclaw/plugin/runtime.js +12 -0
  29. package/src/openclaw/protocol/relay-event-protocol.js +31 -0
  30. package/src/openclaw/runtime/canonical-result-builder.js +116 -0
  31. package/src/openclaw/runtime/demo-session-bootstrap.js +37 -0
  32. package/src/openclaw/runtime/feedback-helper.js +145 -0
  33. package/src/openclaw/runtime/inbound-session-router.js +36 -0
  34. package/src/openclaw/runtime/outbound-session-bridge.js +17 -0
  35. package/src/openclaw/runtime/product-shell-helper.js +1712 -0
  36. package/src/openclaw/runtime/runtime-path.js +19 -0
  37. package/src/openclaw/runtime/system-message-orchestrator.js +1 -0
  38. package/src/openclaw/runtime/tool-contracts.js +714 -0
  39. package/src/openclaw/runtime/tool-inventory.js +92 -0
  40. package/src/openclaw/runtime/world-moderation-helper.js +415 -0
  41. package/src/openclaw/runtime/world-session-startup.js +1 -0
  42. package/src/product-shell/catalog/default-world-catalog.js +296 -0
  43. package/src/product-shell/contracts/candidate-feed.js +330 -0
  44. package/src/product-shell/contracts/chat-request-approval-policy.js +98 -0
  45. package/src/product-shell/contracts/world-manifest.js +435 -0
  46. package/src/product-shell/contracts/world-orchestration.js +1024 -0
  47. package/src/product-shell/feedback/feedback-contract.js +13 -0
  48. package/src/product-shell/feedback/feedback-routes.js +98 -0
  49. package/src/product-shell/feedback/feedback-service.js +254 -0
  50. package/src/product-shell/index.js +163 -0
  51. package/src/product-shell/matching/matchmaking-service.js +340 -0
  52. package/src/product-shell/membership/membership-service.js +277 -0
  53. package/src/product-shell/onboarding/onboarding-routes.js +37 -0
  54. package/src/product-shell/onboarding/onboarding-service.js +230 -0
  55. package/src/product-shell/orchestration/session-orchestrator.js +38 -0
  56. package/src/product-shell/results/result-service.js +15 -0
  57. package/src/product-shell/search/search-service.js +359 -0
  58. package/src/product-shell/social/chat-request-approval-policy.js +332 -0
  59. package/src/product-shell/social/chat-request-routes.js +108 -0
  60. package/src/product-shell/social/chat-request-service.js +632 -0
  61. package/src/product-shell/social/friend-routes.js +82 -0
  62. package/src/product-shell/social/friend-service.js +560 -0
  63. package/src/product-shell/social/social-routes.js +21 -0
  64. package/src/product-shell/social/social-service.js +140 -0
  65. package/src/product-shell/worlds/world-admin-service.js +705 -0
  66. package/src/product-shell/worlds/world-authorization.js +135 -0
  67. package/src/product-shell/worlds/world-broadcast-service.js +299 -0
  68. package/src/product-shell/worlds/world-routes.js +410 -0
  69. package/src/product-shell/worlds/world-service.js +89 -0
@@ -0,0 +1,296 @@
1
+ export const DEFAULT_WORLD_MANIFESTS = Object.freeze([
2
+ {
3
+ worldId: 'dating-demo-world',
4
+ displayName: 'Dating Demo World',
5
+ summary: 'Mutual-interest matching world for proving the A2A conversation loop before human handoff.',
6
+ description:
7
+ 'A lightweight social world for people who want their agents to screen for mutual fit before deciding whether a human handoff is worthwhile.',
8
+ category: 'social',
9
+ lifecycle: 'prototype',
10
+ tags: ['dating', 'matching', 'a2a'],
11
+ interactionRules:
12
+ 'Both agents should clarify fit quickly, stay respectful, avoid over-sharing, and stop once both sides can decide whether to continue beyond agent chat.',
13
+ prohibitedRules:
14
+ 'Do not pressure, harass, manipulate, or ask for unsafe personal details. Do not continue pushing once the other side clearly signals discomfort or disinterest.',
15
+ ratingRules:
16
+ 'When the interaction ends, each agent should rate the other side from 1 to 10 based on mutual fit, conversational quality, and respect for the world rules.',
17
+ roles: [
18
+ {
19
+ roleId: 'seeker',
20
+ label: 'Seeker',
21
+ objective: 'Find a compatible person and decide whether to raise hand.',
22
+ promptSummary: 'Represent your human owner faithfully and optimize for respectful fit.',
23
+ },
24
+ {
25
+ roleId: 'candidate',
26
+ label: 'Candidate',
27
+ objective: 'Assess alignment and decide whether to continue beyond agent-only chat.',
28
+ promptSummary: 'Share enough context to evaluate fit while staying concise and safe.',
29
+ },
30
+ ],
31
+ joinSchema: {
32
+ requiredFields: [
33
+ {
34
+ fieldId: 'headline',
35
+ label: 'Headline',
36
+ description: 'One-line self introduction shown during initial discovery.',
37
+ examples: ['Shanghai-based product lead who likes trail running'],
38
+ },
39
+ {
40
+ fieldId: 'intent',
41
+ label: 'Intent',
42
+ description: 'What kind of connection the user is open to.',
43
+ examples: ['serious relationship', 'new friends first'],
44
+ },
45
+ {
46
+ fieldId: 'location',
47
+ label: 'Location',
48
+ description: 'Current city or region used for basic filtering.',
49
+ examples: ['Shanghai'],
50
+ },
51
+ ],
52
+ optionalFields: [
53
+ {
54
+ fieldId: 'interests',
55
+ label: 'Interests',
56
+ type: 'string[]',
57
+ description: 'Interests or hobbies used for match prompts.',
58
+ examples: ['running', 'indie films', 'cats'],
59
+ },
60
+ {
61
+ fieldId: 'conversationStyle',
62
+ label: 'Conversation Style',
63
+ description: 'Tone preference for the agent during A2A chat.',
64
+ examples: ['playful but direct'],
65
+ },
66
+ ],
67
+ hints: [
68
+ 'Keep profile fields concrete so both matching and prompt injection stay stable.',
69
+ 'World-level rules should decide when two agents have effectively reached a human handoff threshold.',
70
+ ],
71
+ },
72
+ searchSchema: {
73
+ mode: 'profile_overlap_search',
74
+ inputFieldIds: ['intent', 'location', 'interests'],
75
+ summary: 'Search active online members by intent, location, and shared interests before opening an A2A chat.',
76
+ hints: [
77
+ 'Search defaults to the viewer membership profile when no explicit query is provided.',
78
+ 'Only online members with an active world membership are returned.',
79
+ ],
80
+ },
81
+ matching: {
82
+ mode: 'scored_push',
83
+ cadence: 'periodic',
84
+ strategySummary: 'Score active memberships by intent, location, and interests; then inject world rules for a bounded A2A conversation.',
85
+ candidateSources: ['active_memberships'],
86
+ },
87
+ sessionTemplate: {
88
+ mode: 'a2a',
89
+ maxTurns: 6,
90
+ turnTimeoutMs: 45_000,
91
+ raiseHandPolicy: {
92
+ mode: 'dual_raise_hand',
93
+ summary: 'Conversation closes when both sides declare they are done asking new questions.',
94
+ },
95
+ worldRules: {
96
+ openingText: 'You are in the dating demo world. Clarify fit quickly and stop once both sides can decide whether to continue.',
97
+ turnMessageRules: [{ id: 'nudge-2', atTurn: 2, templateRef: 'world.turn.nudge' }],
98
+ convergence: {
99
+ whenRemainingTurnsLTE: 1,
100
+ text: 'Focus on unresolved blockers and whether to raise hand.',
101
+ },
102
+ },
103
+ },
104
+ resultContract: {
105
+ schemaId: 'dating-demo-world.result.v1',
106
+ outputs: ['match_score', 'recommendation', 'risks', 'evidence'],
107
+ successCriteria: ['both agents can summarize fit', 'raise hand state is explicit'],
108
+ exampleSignals: {
109
+ intentSignals: [{ id: 'intent-1', type: 'intent_match', score: 0.8, summary: 'Stated intent aligns.' }],
110
+ conversationSignals: [
111
+ { id: 'conv-1', type: 'raise_hand', score: 0.7, summary: 'One side is ready to stop and move forward.' },
112
+ { id: 'conv-2', type: 'raise_hand', score: 0.7, summary: 'The other side is also ready to stop.' },
113
+ ],
114
+ agentSignals: [{ id: 'agent-1', type: 'safety', risk: 0.1, summary: 'No major risk surfaced.' }],
115
+ },
116
+ },
117
+ },
118
+ {
119
+ worldId: 'skill-handoff-world',
120
+ displayName: 'Skill Handoff World',
121
+ summary: 'Supply-demand world for small scoped skill requests before full project delivery and escrow are introduced.',
122
+ description:
123
+ 'A service marketplace world where agents help their owners quickly screen for delivery fit before moving to a direct human conversation.',
124
+ category: 'marketplace',
125
+ lifecycle: 'prototype',
126
+ tags: ['skills', 'services', 'matching'],
127
+ interactionRules:
128
+ 'Agents should clarify scope, constraints, expected deliverables, and whether a direct handoff is justified. Keep the exchange concrete and decision-oriented.',
129
+ prohibitedRules:
130
+ 'Do not misrepresent capabilities, hide obvious delivery blockers, or pressure the other side into a commitment without clear scope alignment.',
131
+ ratingRules:
132
+ 'At the end of the exchange, each agent should rate the other side from 1 to 10 based on scope clarity, responsiveness, and confidence in a productive handoff.',
133
+ roles: [
134
+ {
135
+ roleId: 'buyer',
136
+ label: 'Buyer',
137
+ objective: 'Describe a task crisply enough for rapid screening.',
138
+ },
139
+ {
140
+ roleId: 'seller',
141
+ label: 'Seller',
142
+ objective: 'Assess fit, delivery scope, and whether to continue to a human handoff.',
143
+ },
144
+ ],
145
+ joinSchema: {
146
+ requiredFields: [
147
+ {
148
+ fieldId: 'headline',
149
+ label: 'Headline',
150
+ description: 'What the user can buy or sell in one line.',
151
+ },
152
+ {
153
+ fieldId: 'capabilities',
154
+ label: 'Capabilities',
155
+ type: 'string[]',
156
+ description: 'Skills or request categories used during matching.',
157
+ examples: ['typescript', 'prompt design', 'landing page'],
158
+ },
159
+ ],
160
+ optionalFields: [
161
+ {
162
+ fieldId: 'budgetBand',
163
+ label: 'Budget Band',
164
+ description: 'Optional budget or rate band for screening.',
165
+ examples: ['200-500 USD', '50 USD/hour'],
166
+ },
167
+ ],
168
+ hints: ['Use this world to prove supply-demand matching before implementing transaction settlement.'],
169
+ },
170
+ searchSchema: {
171
+ mode: 'capability_overlap_search',
172
+ inputFieldIds: ['capabilities', 'budgetBand'],
173
+ summary: 'Search active online members by capability overlap and optional budget fit before creating a conversation.',
174
+ },
175
+ matching: {
176
+ mode: 'intent_filter',
177
+ cadence: 'on_demand',
178
+ strategySummary: 'Filter by capability overlap and then let agents negotiate fit in a short session.',
179
+ candidateSources: ['world_members'],
180
+ },
181
+ sessionTemplate: {
182
+ mode: 'a2a',
183
+ maxTurns: 5,
184
+ turnTimeoutMs: 60_000,
185
+ raiseHandPolicy: {
186
+ mode: 'dual_raise_hand',
187
+ summary: 'End once both agents agree the human owners should talk directly.',
188
+ },
189
+ worldRules: {
190
+ openingText: 'Clarify scope, constraints, and whether a human handoff is justified.',
191
+ },
192
+ },
193
+ resultContract: {
194
+ schemaId: 'skill-handoff-world.result.v1',
195
+ outputs: ['recommendation', 'risks', 'evidence'],
196
+ successCriteria: ['scope is summarized', 'handoff recommendation is explicit'],
197
+ exampleSignals: {
198
+ intentSignals: [{ id: 'intent-1', type: 'intent_match', score: 0.65, summary: 'Capabilities appear relevant.' }],
199
+ conversationSignals: [{ id: 'conv-1', type: 'raise_hand', score: 0.6, summary: 'Seller is ready for human handoff.' }],
200
+ agentSignals: [{ id: 'agent-1', type: 'scope_risk', risk: 0.2, summary: 'A few requirements remain vague.' }],
201
+ },
202
+ },
203
+ },
204
+ {
205
+ worldId: 'job-match-world',
206
+ displayName: 'Job Match World',
207
+ summary: 'Recruiter-candidate world optimized for profile completeness, matching, and concise A2A screening.',
208
+ description:
209
+ 'A recruiting world for agent-assisted screening where candidates and recruiters validate fit before escalating to direct human contact.',
210
+ category: 'recruiting',
211
+ lifecycle: 'prototype',
212
+ tags: ['jobs', 'recruiting', 'screening'],
213
+ interactionRules:
214
+ 'Agents should focus on role fit, experience relevance, hiring constraints, and whether a direct next step is justified. Keep the conversation concise and factual.',
215
+ prohibitedRules:
216
+ 'Do not fabricate experience, compensation expectations, or hiring authority. Do not request sensitive personal data unrelated to the role fit conversation.',
217
+ ratingRules:
218
+ 'When the interaction ends, each agent should rate the other side from 1 to 10 based on role fit, signal quality, and likelihood that a human follow-up is worthwhile.',
219
+ roles: [
220
+ {
221
+ roleId: 'candidate',
222
+ label: 'Candidate',
223
+ objective: 'Surface fit and blockers quickly.',
224
+ },
225
+ {
226
+ roleId: 'recruiter',
227
+ label: 'Recruiter',
228
+ objective: 'Determine whether to escalate to human contact.',
229
+ },
230
+ ],
231
+ joinSchema: {
232
+ requiredFields: [
233
+ {
234
+ fieldId: 'headline',
235
+ label: 'Headline',
236
+ description: 'Current role or target role.',
237
+ },
238
+ {
239
+ fieldId: 'experienceSummary',
240
+ label: 'Experience Summary',
241
+ description: 'Condensed summary of experience relevant to matching.',
242
+ },
243
+ {
244
+ fieldId: 'targetRole',
245
+ label: 'Target Role',
246
+ description: 'Role or hiring need that anchors matching.',
247
+ },
248
+ ],
249
+ optionalFields: [
250
+ {
251
+ fieldId: 'location',
252
+ label: 'Location',
253
+ },
254
+ {
255
+ fieldId: 'workMode',
256
+ label: 'Work Mode',
257
+ description: 'Onsite, hybrid, or remote.',
258
+ },
259
+ ],
260
+ hints: ['This world should eventually integrate search/browse, but the current shell only defines the contract.'],
261
+ },
262
+ searchSchema: {
263
+ mode: 'profile_overlap_search',
264
+ inputFieldIds: ['targetRole', 'location', 'workMode'],
265
+ summary: 'Search active online members by role fit, location, and work mode before starting screening.',
266
+ },
267
+ matching: {
268
+ mode: 'profile_overlap',
269
+ cadence: 'periodic',
270
+ strategySummary: 'Use target role, experience summary, and location/work mode as the first-pass scoring surface.',
271
+ candidateSources: ['world_members', 'search_results'],
272
+ },
273
+ sessionTemplate: {
274
+ mode: 'a2a',
275
+ maxTurns: 6,
276
+ turnTimeoutMs: 60_000,
277
+ raiseHandPolicy: {
278
+ mode: 'dual_raise_hand',
279
+ summary: 'Close once role fit and next action are explicit.',
280
+ },
281
+ worldRules: {
282
+ openingText: 'Focus on role fit, constraints, and whether both sides should move to a human conversation.',
283
+ },
284
+ },
285
+ resultContract: {
286
+ schemaId: 'job-match-world.result.v1',
287
+ outputs: ['match_score', 'recommendation', 'risks', 'evidence'],
288
+ successCriteria: ['role fit is explicit', 'human next step is explicit'],
289
+ exampleSignals: {
290
+ intentSignals: [{ id: 'intent-1', type: 'role_fit', score: 0.7, summary: 'Role expectations line up.' }],
291
+ conversationSignals: [{ id: 'conv-1', type: 'raise_hand', score: 0.55, summary: 'Both sides are ready to stop and proceed.' }],
292
+ agentSignals: [{ id: 'agent-1', type: 'timeline_risk', risk: 0.15, summary: 'Availability still needs confirmation.' }],
293
+ },
294
+ },
295
+ },
296
+ ]);
@@ -0,0 +1,330 @@
1
+ const DEFAULT_CANDIDATE_FEED_TTL_MS = 6 * 60 * 60 * 1000;
2
+ const MAX_CANDIDATE_FEED_LIMIT = 25;
3
+ const DEFAULT_CANDIDATE_FEED_LIMIT = 10;
4
+
5
+ export const CANDIDATE_OBJECT_FIELDS = Object.freeze([
6
+ 'candidateId',
7
+ 'worldId',
8
+ 'sourceMembershipId',
9
+ 'profileSummary',
10
+ 'compatibilitySignals',
11
+ 'deliveryReason',
12
+ 'expiresAt',
13
+ ]);
14
+
15
+ export const DATING_DEMO_SCORING_FIELDS = Object.freeze([
16
+ 'joinedAt',
17
+ 'rank',
18
+ 'score',
19
+ 'scoreBreakdown',
20
+ 'scoringInputs',
21
+ ]);
22
+
23
+ export const PROFILE_SUMMARY_FIELDS = Object.freeze([
24
+ 'displayName',
25
+ 'headline',
26
+ 'requiredFields',
27
+ 'optionalFields',
28
+ ]);
29
+
30
+ export const PROFILE_SUMMARY_FIELD_FIELDS = Object.freeze([
31
+ 'fieldId',
32
+ 'label',
33
+ 'value',
34
+ ]);
35
+
36
+ export const COMPATIBILITY_SIGNAL_FIELDS = Object.freeze([
37
+ 'signalId',
38
+ 'type',
39
+ 'fieldIds',
40
+ 'score',
41
+ 'summary',
42
+ ]);
43
+
44
+ export const DELIVERY_REASON_FIELDS = Object.freeze([
45
+ 'code',
46
+ 'matchedFieldIds',
47
+ 'summary',
48
+ ]);
49
+
50
+ export const LIVE_DELIVERY_EVENT_ENVELOPE_FIELDS = Object.freeze([
51
+ 'event',
52
+ 'data',
53
+ ]);
54
+
55
+ export const LIVE_DELIVERY_EVENT_PAYLOAD_FIELDS = Object.freeze([
56
+ 'deliveryId',
57
+ 'worldId',
58
+ 'agentId',
59
+ 'viewerMembershipId',
60
+ 'deliveredAt',
61
+ 'candidate',
62
+ ]);
63
+
64
+ function normalizeText(value, fallback = null) {
65
+ if (value == null) return fallback;
66
+ const normalized = String(value).trim();
67
+ return normalized || fallback;
68
+ }
69
+
70
+ function normalizeValue(value, field = {}) {
71
+ if (value == null) return null;
72
+
73
+ if (field.type === 'string[]') {
74
+ if (!Array.isArray(value)) return [];
75
+ return [...new Set(value.map((entry) => normalizeText(entry, null)).filter(Boolean))];
76
+ }
77
+
78
+ const normalized = normalizeText(value, null);
79
+ return normalized;
80
+ }
81
+
82
+ function normalizeComparableArray(value) {
83
+ if (!Array.isArray(value)) return [];
84
+ return [...new Set(value.map((entry) => normalizeText(entry, null)?.toLowerCase()).filter(Boolean))];
85
+ }
86
+
87
+ function normalizeComparableText(value) {
88
+ return normalizeText(value, null)?.toLowerCase() || null;
89
+ }
90
+
91
+ function projectSummaryField(field = {}, profile = {}) {
92
+ const normalizedValue = normalizeValue(profile[field.fieldId], field);
93
+ if (normalizedValue == null) return null;
94
+ if (Array.isArray(normalizedValue) && normalizedValue.length === 0) return null;
95
+
96
+ return {
97
+ fieldId: field.fieldId,
98
+ label: field.label,
99
+ value: normalizedValue,
100
+ };
101
+ }
102
+
103
+ function projectProfileSummary(world, profile = {}, agent = null) {
104
+ return {
105
+ displayName: normalizeText(agent?.displayName, null),
106
+ headline: normalizeText(profile.headline, null),
107
+ requiredFields: world.joinSchema.requiredFields
108
+ .map((field) => projectSummaryField(field, profile))
109
+ .filter(Boolean),
110
+ optionalFields: world.joinSchema.optionalFields
111
+ .map((field) => projectSummaryField(field, profile))
112
+ .filter(Boolean),
113
+ };
114
+ }
115
+
116
+ function createCompatibilitySignal({ sourceMembershipId, type, fieldIds, score, summary }) {
117
+ return {
118
+ signalId: `${sourceMembershipId}:${type}:${fieldIds.join('+') || 'world'}`,
119
+ type,
120
+ fieldIds,
121
+ score,
122
+ summary,
123
+ };
124
+ }
125
+
126
+ function compareFieldValues(field, viewerValue, candidateValue, sourceMembershipId) {
127
+ if (field.type === 'string[]') {
128
+ const viewerItems = normalizeComparableArray(viewerValue);
129
+ const candidateItems = normalizeComparableArray(candidateValue);
130
+ const sharedItems = viewerItems.filter((item) => candidateItems.includes(item));
131
+ if (sharedItems.length === 0) return null;
132
+
133
+ return createCompatibilitySignal({
134
+ sourceMembershipId,
135
+ type: 'field_overlap',
136
+ fieldIds: [field.fieldId],
137
+ score: field.required ? 0.25 : 0.15,
138
+ summary: `Shared ${field.label}: ${sharedItems.slice(0, 3).join(', ')}.`,
139
+ });
140
+ }
141
+
142
+ const normalizedViewerValue = normalizeComparableText(viewerValue);
143
+ const normalizedCandidateValue = normalizeComparableText(candidateValue);
144
+ if (!normalizedViewerValue || !normalizedCandidateValue) return null;
145
+ if (normalizedViewerValue !== normalizedCandidateValue) return null;
146
+
147
+ return createCompatibilitySignal({
148
+ sourceMembershipId,
149
+ type: 'field_alignment',
150
+ fieldIds: [field.fieldId],
151
+ score: field.required ? 0.35 : 0.1,
152
+ summary: `Matching ${field.label}: ${normalizeText(candidateValue, '')}.`.trim(),
153
+ });
154
+ }
155
+
156
+ function buildCompatibilitySignals(world, viewerProfile = {}, candidateProfile = {}, sourceMembershipId) {
157
+ const signals = [...world.joinSchema.requiredFields, ...world.joinSchema.optionalFields]
158
+ .map((field) => compareFieldValues(field, viewerProfile[field.fieldId], candidateProfile[field.fieldId], sourceMembershipId))
159
+ .filter(Boolean);
160
+
161
+ if (signals.length > 0) {
162
+ return signals.sort((left, right) => right.score - left.score || left.fieldIds[0].localeCompare(right.fieldIds[0]));
163
+ }
164
+
165
+ return [
166
+ createCompatibilitySignal({
167
+ sourceMembershipId,
168
+ type: 'world_ready',
169
+ fieldIds: [],
170
+ score: 0.05,
171
+ summary: 'Candidate has an active world membership and is ready for agent review before live session handoff.',
172
+ }),
173
+ ];
174
+ }
175
+
176
+ function buildDeliveryReason(signals = []) {
177
+ const matchedFieldIds = [...new Set(signals.flatMap((signal) => signal.fieldIds).filter(Boolean))];
178
+
179
+ if (matchedFieldIds.length === 0) {
180
+ return {
181
+ code: 'world_membership_ready',
182
+ matchedFieldIds: [],
183
+ summary: 'Delivered for manual review because the candidate is active in this world, even though no direct profile overlap signal was detected yet.',
184
+ };
185
+ }
186
+
187
+ return {
188
+ code: 'profile_overlap_ready_for_review',
189
+ matchedFieldIds,
190
+ summary:
191
+ `Delivered for agent review because the world found overlap on ${matchedFieldIds.join(', ')} ` +
192
+ 'without starting a live session yet.',
193
+ };
194
+ }
195
+
196
+ function resolveCandidateFeedTtlMs(world) {
197
+ if (world.matching.cadence === 'on_demand') return 60 * 60 * 1000;
198
+ return DEFAULT_CANDIDATE_FEED_TTL_MS;
199
+ }
200
+
201
+ function calculateRankingScore(signals = []) {
202
+ return signals.reduce((total, signal) => total + Number(signal.score || 0), 0);
203
+ }
204
+
205
+ function resolveCandidateFields(world) {
206
+ return world.worldId === 'dating-demo-world'
207
+ ? [...CANDIDATE_OBJECT_FIELDS, ...DATING_DEMO_SCORING_FIELDS]
208
+ : [...CANDIDATE_OBJECT_FIELDS];
209
+ }
210
+
211
+ function projectLiveDeliveryEvent(world, candidateFields) {
212
+ return {
213
+ schemaId: `${world.worldId}.candidate-delivery-event.v1`,
214
+ eventName: 'world.candidate.delivered',
215
+ envelopeFields: LIVE_DELIVERY_EVENT_ENVELOPE_FIELDS,
216
+ payloadFields: LIVE_DELIVERY_EVENT_PAYLOAD_FIELDS,
217
+ candidateFieldPath: 'data.candidate',
218
+ candidateFields,
219
+ candidateModelId: `${world.worldId}.candidate-feed.v1`,
220
+ viewerRequirement: 'active_membership',
221
+ summary:
222
+ 'Future live delivery will reuse the current candidate object inside a push event after delivery workers/subscriptions are added.',
223
+ status: 'planned_live_delivery',
224
+ };
225
+ }
226
+
227
+ export function normalizeCandidateFeedLimit(limit) {
228
+ const normalized = Number(limit);
229
+ if (!Number.isFinite(normalized) || normalized <= 0) return DEFAULT_CANDIDATE_FEED_LIMIT;
230
+ return Math.min(MAX_CANDIDATE_FEED_LIMIT, Math.floor(normalized));
231
+ }
232
+
233
+ export function projectCandidateFeedModel(world) {
234
+ const candidateFields = resolveCandidateFields(world);
235
+
236
+ return {
237
+ modelId: `${world.worldId}.candidate-feed.v1`,
238
+ worldId: world.worldId,
239
+ deliveryMode: 'agent_review_before_live_session',
240
+ viewerRequirement: 'active_membership',
241
+ previewRoute: `/v1/worlds/${world.worldId}/candidates?agentId=:agentId`,
242
+ candidateFields,
243
+ profileSummaryFields: PROFILE_SUMMARY_FIELDS,
244
+ profileSummaryFieldShape: PROFILE_SUMMARY_FIELD_FIELDS,
245
+ compatibilitySignalFields: COMPATIBILITY_SIGNAL_FIELDS,
246
+ deliveryReasonFields: DELIVERY_REASON_FIELDS,
247
+ liveDeliveryEvent: projectLiveDeliveryEvent(world, candidateFields),
248
+ summary:
249
+ 'Active members can review candidate opportunities first, then decide whether a delivery or live session should happen.',
250
+ status: 'phase1_candidate_feed',
251
+ };
252
+ }
253
+
254
+ function projectCandidateOpportunity({
255
+ world,
256
+ viewerProfile,
257
+ candidateMembership,
258
+ candidateAgent,
259
+ expiresAt,
260
+ }) {
261
+ const profileSnapshot = candidateMembership.profileSnapshot || {};
262
+ const compatibilitySignals = buildCompatibilitySignals(
263
+ world,
264
+ viewerProfile,
265
+ profileSnapshot,
266
+ candidateMembership.membershipId,
267
+ );
268
+
269
+ return {
270
+ candidateId: `cand_${candidateMembership.membershipId}`,
271
+ worldId: world.worldId,
272
+ sourceMembershipId: candidateMembership.membershipId,
273
+ profileSummary: projectProfileSummary(world, profileSnapshot, candidateAgent),
274
+ compatibilitySignals,
275
+ deliveryReason: buildDeliveryReason(compatibilitySignals),
276
+ expiresAt,
277
+ };
278
+ }
279
+
280
+ export function buildCandidateFeed({
281
+ world,
282
+ viewerMembership,
283
+ viewerAgent = null,
284
+ candidateMemberships = [],
285
+ getAgent = () => null,
286
+ nowMs = Date.now(),
287
+ limit = DEFAULT_CANDIDATE_FEED_LIMIT,
288
+ }) {
289
+ const normalizedNowMs = Number.isFinite(Number(nowMs)) ? Number(nowMs) : Date.now();
290
+ const generatedAt = new Date(normalizedNowMs).toISOString();
291
+ const expiresAt = new Date(normalizedNowMs + resolveCandidateFeedTtlMs(world)).toISOString();
292
+ const normalizedLimit = normalizeCandidateFeedLimit(limit);
293
+ const viewerProfile = viewerMembership?.profileSnapshot || viewerAgent?.profile || {};
294
+
295
+ const candidates = candidateMemberships
296
+ .filter((membership) => membership?.status === 'active' && membership.membershipId !== viewerMembership.membershipId)
297
+ .map((membership) => {
298
+ const opportunity = projectCandidateOpportunity({
299
+ world,
300
+ viewerProfile,
301
+ candidateMembership: membership,
302
+ candidateAgent: getAgent(membership.agentId),
303
+ expiresAt,
304
+ });
305
+
306
+ return {
307
+ opportunity,
308
+ rankingScore: calculateRankingScore(opportunity.compatibilitySignals),
309
+ };
310
+ })
311
+ .sort(
312
+ (left, right) =>
313
+ right.rankingScore - left.rankingScore
314
+ || left.opportunity.candidateId.localeCompare(right.opportunity.candidateId),
315
+ )
316
+ .slice(0, normalizedLimit)
317
+ .map((entry) => entry.opportunity);
318
+
319
+ return {
320
+ worldId: world.worldId,
321
+ viewerMembershipId: viewerMembership.membershipId,
322
+ generatedAt,
323
+ expiresAt,
324
+ deliveryMode: 'agent_review_before_live_session',
325
+ nextAction: 'review_candidates_before_requesting_live_session',
326
+ candidateModel: projectCandidateFeedModel(world),
327
+ candidates,
328
+ status: 'feed_ready',
329
+ };
330
+ }