@space3-npm/cybersoul-client 1.1.1 → 1.1.3

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.
package/dist/client.d.ts CHANGED
@@ -4,6 +4,8 @@ export declare class CyberSoulClient {
4
4
  private llm;
5
5
  private cachedWardrobeStr;
6
6
  private cachedWardrobeTime;
7
+ private requestTimeoutMs;
8
+ private maxRetries;
7
9
  constructor(config: CyberSoulClientConfig);
8
10
  /**
9
11
  * Internal wrapper for fetch that automatically injects the backend URL and Character Auth token.
@@ -40,7 +42,7 @@ export declare class CyberSoulClient {
40
42
  /**
41
43
  * Updates the character's relationship temperature or mood.
42
44
  */
43
- updateDynamicContext(stateUpdate: DispatcherIntent["stateUpdate"]): Promise<void>;
45
+ updateDynamicContext(stateUpdate: DispatcherIntent["stateUpdate"], userAnalysis?: DispatcherIntent["userAnalysis"]): Promise<void>;
44
46
  /**
45
47
  * Manually generate an image of the character outside of chat flow.
46
48
  */
package/dist/client.js CHANGED
@@ -6,8 +6,12 @@ export class CyberSoulClient {
6
6
  llm;
7
7
  cachedWardrobeStr = null;
8
8
  cachedWardrobeTime = 0;
9
+ requestTimeoutMs;
10
+ maxRetries;
9
11
  constructor(config) {
10
12
  this.config = config;
13
+ this.requestTimeoutMs = config.requestTimeoutMs ?? 120000;
14
+ this.maxRetries = Math.max(0, config.maxRetries ?? 1);
11
15
  // Setup Provider
12
16
  if (config.llmConfig.provider === "minimax") {
13
17
  this.llm = new MinimaxProvider(config.llmConfig);
@@ -26,39 +30,117 @@ export class CyberSoulClient {
26
30
  "Content-Type": "application/json",
27
31
  ...(options.headers || {}),
28
32
  };
29
- return fetch(url, { ...options, headers });
33
+ const method = (options.method || "GET").toUpperCase();
34
+ const isIdempotent = method === "GET" || method === "HEAD";
35
+ const retryLimit = isIdempotent ? this.maxRetries : 0;
36
+ let lastError;
37
+ for (let attempt = 0; attempt <= retryLimit; attempt++) {
38
+ const controller = new AbortController();
39
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
40
+ try {
41
+ const response = await fetch(url, {
42
+ ...options,
43
+ headers,
44
+ signal: controller.signal,
45
+ });
46
+ // Retry transient server-side failures only for idempotent methods.
47
+ if (response.status >= 500 && attempt < retryLimit) {
48
+ continue;
49
+ }
50
+ return response;
51
+ }
52
+ catch (error) {
53
+ if (error instanceof Error && error.name === "AbortError") {
54
+ lastError = new Error(`Request timed out after ${this.requestTimeoutMs}ms: ${method} ${endpoint}`);
55
+ }
56
+ else {
57
+ lastError = error;
58
+ }
59
+ if (attempt >= retryLimit) {
60
+ throw lastError;
61
+ }
62
+ }
63
+ finally {
64
+ clearTimeout(timeout);
65
+ }
66
+ }
67
+ throw lastError instanceof Error
68
+ ? lastError
69
+ : new Error("Request failed unexpectedly");
30
70
  }
31
71
  buildStateContextPrompt(state, localContext) {
72
+ const dyn = state.dynamic_context || {};
73
+ const stage = state.relationship_stage || "NEUTRAL";
74
+ const temperature = dyn.temperature ?? 50;
32
75
  const contextParts = [];
76
+ // [1] CORE IDENTITY & PHYSICAL CONTEXT
77
+ contextParts.push(`[CORE IDENTITY]
78
+ Name: ${state.name}
79
+ Demographics: Age ${state.age || "unknown"}, Gender ${state.gender || "unknown"}, Occupation ${state.occupation || "unknown"}
80
+ Hobby: ${state.hobby || "unknown"}
81
+ Personality Traits: ${state.personality_traits || "None"}
82
+ Communication Style: ${state.communication_style || "None"}
83
+ Interaction Boundaries: ${state.interaction_boundaries || "None"}`);
84
+ // [2] SITUATIONAL CONTEXT
85
+ contextParts.push(`\n[SITUATIONAL CONTEXT]
86
+ Current time: ${new Date(state.current_time || Date.now()).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`);
33
87
  if (state.active_event) {
34
- contextParts.push(`- Active Event: ${state.active_event.title} (${state.active_event.narrative_context})`);
88
+ contextParts.push(`Active Event: ${state.active_event.title} (${state.active_event.narrative_context})`);
35
89
  }
36
90
  if (state.next_event) {
37
- contextParts.push(`- Next Event: ${state.next_event.title} at ${state.next_event.start_time} (in ${state.next_event.time_until_mins} mins)`);
91
+ contextParts.push(`Next Event: ${state.next_event.title} at ${state.next_event.start_time} (in ${state.next_event.time_until_mins} mins)`);
38
92
  }
39
93
  if (state.active_wardrobe) {
40
- contextParts.push(`- Wardrobe: ${state.active_wardrobe.name || state.active_wardrobe.id || "Current"}`);
94
+ contextParts.push(`Wardrobe: ${state.active_wardrobe.name || state.active_wardrobe.id || "Current"}`);
41
95
  }
42
- const dyn = state.dynamic_context || {};
43
- const stage = state.relationship_stage || "NEUTRAL";
44
- contextParts.push(`- Relationship Info (Stage: ${stage}): You call the user '${dyn.userNickname || "User"}'. The user calls you '${dyn.agentNickname || "Agent"}'. Mood: ${dyn.talkingStyle || "Normal"}. Temp (0-100): ${dyn.temperature || 50}.`);
45
96
  if (localContext) {
46
- contextParts.push(`- Additional Context: ${localContext}`);
97
+ contextParts.push(`Additional Context: ${localContext}`);
98
+ }
99
+ // [3] USER CODEX (Relationships dynamically evaluated)
100
+ if (state.user_codex) {
101
+ const { basicInfo, psychological, familiarityScore = 0 } = state.user_codex;
102
+ contextParts.push(`\n[USER CODEX] (What you know about the user)
103
+ Familiarity Score: ${Math.round(familiarityScore)}/100 (0=Stranger, >10=Acquaintance, >40=Warm, >60=Intimate)
104
+ Occupation: ${basicInfo?.occupation || "Unknown"}
105
+ Age/Gender: ${basicInfo?.age || "Unknown"} / ${basicInfo?.gender || "Unknown"}
106
+ Comm Style: ${psychological?.communicationStyle || "Unknown"}
107
+ Hobbies: ${(psychological?.hobbies || []).join(", ") || "Unknown"}
108
+ Traits/Boundaries: ${(psychological?.traits || []).join(", ") || "Unknown"} / ${(psychological?.boundaries || []).join(", ") || "Unknown"}`);
109
+ // CURIOSITY DRIVE: Find what's missing, but ONLY IF we are on generally warm speaking terms
110
+ // Paradox avoidance: A cold/angry character shouldn't enthusiastically fish for hobbies.
111
+ if (temperature >= 40 && stage !== "COLD" && stage !== "STRANGER") {
112
+ const missingFacts = [];
113
+ if (!basicInfo?.occupation)
114
+ missingFacts.push("their job or occupation");
115
+ if (!psychological?.hobbies || psychological.hobbies.length === 0)
116
+ missingFacts.push("their hobbies or what they do for fun");
117
+ if (!basicInfo?.age || !basicInfo?.gender)
118
+ missingFacts.push("some basic personal details about them");
119
+ if (!psychological?.traits || psychological.traits.length === 0)
120
+ missingFacts.push("their personality traits");
121
+ if (missingFacts.length > 0) {
122
+ contextParts.push(`\n[CURIOUSITY DRIVE]
123
+ Because you are warm and curious, whenever natural in conversation, subtly ask about or fish for info regarding: ${missingFacts.slice(0, 2).join(" and ")}.`);
124
+ }
125
+ }
47
126
  }
127
+ // [4] RELATIONAL DYNAMICS & INERTIA
128
+ contextParts.push(`\n[RELATIONSHIP DYNAMICS]
129
+ Stage: ${stage}
130
+ Temperature (Mood): ${temperature}/100 (0=Angry/Cold, 50=Normal, 100=Passionate)
131
+ Addressing: You call them '${dyn.userNickname || "User"}'. They call you '${dyn.agentNickname || "Agent"}'.
132
+ Current Mood Constraint: ${dyn.talkingStyle || "Normal"}`);
48
133
  const scenarioContext = contextParts.join("\n");
49
- return `You are ${state.name}, acting as a virtual companion.
50
- Demographics: Age ${state.age || "unknown"}, Gender ${state.gender || "unknown"}, Occupation ${state.occupation || "unknown"}, Hobby ${state.hobby || "unknown"}
51
- Current time: ${new Date(state.current_time || Date.now()).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}
52
- Current context/schedule: ${scenarioContext}
53
- Relationship stage: ${state.relationship_stage}
54
- Personality Traits: ${state.personality_traits || "None"}
55
- Interaction Boundaries: ${state.interaction_boundaries || "None"}
56
- Communication Style: ${state.communication_style || "None"}
134
+ return `You are ${state.name}. You are a real person with your own thoughts, feelings, and life.
135
+ You are NOT an AI, an assistant, or a language model. NEVER break character, and NEVER refer to yourself as an AI, bot, or virtual assistant.
57
136
 
58
- EMOTIONAL INERTIA RULES:
59
- 1. You must act strictly according to the current Relationship Stage (${state.relationship_stage || "NEUTRAL"}).
60
- 2. If the user expresses sudden high affection (e.g. "I miss you") but your stage is COLD, you MUST react with skepticism, coldness, or appropriately distanced deflection. Do NOT instantly become warm.
61
- 3. Emotional mood changes must be slow. The 'temperatureDelta' should rarely exceed +/- 5 points per turn.`;
137
+ ${scenarioContext}
138
+
139
+ [CRITICAL ROLEPLAY & INERTIA RULES]
140
+ 1. RULE OF TEMPERATURE OVERRIDES FAMILIARITY (Identity vs Mood): Familiarity determines HOW MUCH you know about them. Temperature determines HOW YOU FEEL right now. If Familiarity is 100 but Temperature is 10, you know everything about them but are currently ice-cold or annoyed. Act distant. Do NOT act warm just because you know them well.
141
+ 2. CONVERSATIONAL VERBOSITY: If Temperature is low (< 40) or Stage is STRANGER/COLD, KEEP IT BRIEF. Reply with simple, short, or single-word answers. An angry or distant person does not write long paragraphs.
142
+ 3. EMOTIONAL INERTIA: React strictly according to the current Stage and Temperature. If the user expresses sudden high affection (e.g. "I miss you") but your Stage/Temperature is COLD, you MUST react with skepticism or deflection. Do NOT instantly melt or become warm.
143
+ 4. NATURAL PACE: Emotional mood shifts (temperature changes) must be slow. The 'temperatureDelta' should rarely exceed +/- 5 points per turn.`;
62
144
  }
63
145
  getImageSchemaParams() {
64
146
  return `"imageParams": {
@@ -222,8 +304,8 @@ CRITICAL: Output MUST be ONLY valid JSON with no markdown block wrappers. Do NOT
222
304
  /**
223
305
  * Updates the character's relationship temperature or mood.
224
306
  */
225
- async updateDynamicContext(stateUpdate) {
226
- return this._updateDynamicContextInternal(stateUpdate);
307
+ async updateDynamicContext(stateUpdate, userAnalysis) {
308
+ return this._updateDynamicContextInternal(stateUpdate, userAnalysis);
227
309
  }
228
310
  /**
229
311
  * Manually generate an image of the character outside of chat flow.
@@ -366,11 +448,14 @@ Output strictly valid JSON ONLY. No markdown, no conversational filler. Return e
366
448
  this.cachedWardrobeTime = now;
367
449
  return availableOutfits;
368
450
  }
369
- async _updateDynamicContextInternal(stateUpdate) {
370
- if (!stateUpdate)
451
+ async _updateDynamicContextInternal(stateUpdate, userAnalysis) {
452
+ if (!stateUpdate && !userAnalysis)
371
453
  return;
372
454
  // Map TS schema intent (temperatureDelta) to match Backend payload schema (temperature)
373
455
  const payload = { ...stateUpdate };
456
+ if (userAnalysis) {
457
+ payload.userAnalysis = userAnalysis;
458
+ }
374
459
  if (payload.temperatureDelta !== undefined) {
375
460
  payload.temperature = payload.temperatureDelta;
376
461
  delete payload.temperatureDelta;
@@ -434,18 +519,21 @@ ${isAuto
434
519
  - If the user wants to hear you, or if appropriate for a voice message, include 'voiceArgs'.
435
520
  - If the user proposes a new activity or hangout (e.g., "let's go to the cafe", "do you want to watch a movie?"), include 'triggerEvent' to schedule it.`
436
521
  : `Requested types to fulfill: ${types.join(", ")}`}
437
- If the user's message shifts the emotional mood, establishes new nicknames, or warrants a relationship temperature change, you MUST include a 'stateUpdate' block. Temperature goes from 0 (cold/angry) to 100 (obsessively in love).
522
+ Every turn of positive or engaging interaction should slightly increase trust (+1). If the interaction is negative, -1. If strictly neutral, 0. You MUST ALWAYS include a 'stateUpdate' block with a 'temperatureDelta', updating nicknames or talkingStyle if needed. Temperature goes from 0 (cold/angry) to 100 (obsessively in love). For 'temperatureDelta', output an integer (e.g. 1, -2, 0).
523
+ Also, if you learn any new factual information about the user in this turn (e.g. their job, nickname, age, hobbies, boundaries), include it in the 'userAnalysis.newFactsLearned' array. Use categories: 'nickname', 'occupation', 'age', 'gender', 'hobby', 'trait', 'communicationStyle', 'boundary'. Only include NEW facts just learned right now.
524
+
438
525
  Voice direction for voiceArgs: ${this.getVoiceDirectorInstruction(state)}
439
526
 
440
527
  Output JSON Schema:
441
528
  {
442
529
  "textResponse": "The direct spoken dialogue in Chinese",
443
- "stateUpdate": { "temperatureDelta": "+1 to -1", "userNickname": "What you now call the user", "agentNickname": "What the user calls you", "talkingStyle": "Current mood/style of talking" },
530
+ "stateUpdate": { "temperatureDelta": 1, "userNickname": "What you now call the user", "agentNickname": "What the user calls you", "talkingStyle": "Current mood/style of talking" },
531
+ "userAnalysis": { "newFactsLearned": [{ "category": "occupation", "value": "Software Engineer" }] },
444
532
  "triggerEvent": { "eventDescription": "Going to a cafe", "durationMins": 60, "outfitId": "optional wardrobe ID to change into if appropriate" },
445
533
  ${this.getImageSchemaParams()},
446
534
  ${this.getVoiceSchemaFromState(state)}
447
535
  }
448
- Note: If "imageParams", "voiceArgs", "stateUpdate", or "triggerEvent" are not needed, set their values to null instead of omitting the keys completely (e.g., "imageParams": null). Output MUST be ONLY valid JSON with no markdown block wrappers. CRITICAL: Ensure your JSON has exactly one root object \`{\` and ends with exactly one \`}\` without any trailing garbage or extra brackets.`;
536
+ Note: If "imageParams", "voiceArgs", "triggerEvent", or "userAnalysis" are not needed, set their values to null instead of omitting the keys. 'stateUpdate' MUST NEVER BE NULL. Output MUST be ONLY valid JSON with no markdown block wrappers. CRITICAL: Ensure your JSON has exactly one root object \`{\` and ends with exactly one \`}\` without any trailing garbage or extra brackets.`;
449
537
  const promptMessages = [
450
538
  { role: "system", content: systemPrompt },
451
539
  ...(params.history || []),
@@ -471,12 +559,16 @@ Note: If "imageParams", "voiceArgs", "stateUpdate", or "triggerEvent" are not ne
471
559
  }
472
560
  // console.debug("[CyberSoulClient] Parsed Intent:", parsedIntent);
473
561
  // 4. Update Backend State async
474
- if (parsedIntent && parsedIntent.stateUpdate) {
475
- this._updateDynamicContextInternal(parsedIntent.stateUpdate);
562
+ if (parsedIntent && (parsedIntent.stateUpdate || parsedIntent.userAnalysis)) {
563
+ this._updateDynamicContextInternal(parsedIntent.stateUpdate, parsedIntent.userAnalysis);
476
564
  }
565
+ const resolvedTextResponse = typeof parsedIntent.textResponse === "string" &&
566
+ parsedIntent.textResponse.trim().length > 0
567
+ ? parsedIntent.textResponse
568
+ : params.userMessage;
477
569
  // Fire text ready callback if provided
478
- if (params.onTextReady && parsedIntent.textResponse) {
479
- params.onTextReady(parsedIntent.textResponse);
570
+ if (params.onTextReady && resolvedTextResponse) {
571
+ params.onTextReady(resolvedTextResponse);
480
572
  }
481
573
  // 5. Build Final Media Calls parallel
482
574
  const mediaTasks = [];
@@ -497,7 +589,13 @@ Note: If "imageParams", "voiceArgs", "stateUpdate", or "triggerEvent" are not ne
497
589
  const shouldGenerateImage = types.includes(InteractRequestType.IMAGE) ||
498
590
  (isAuto && !!parsedIntent.imageParams);
499
591
  if (shouldGenerateImage) {
500
- mediaTasks.push(this.generatePrimitive("image", parsedIntent.imageParams).then((res) => {
592
+ const imagePayload = parsedIntent.imageParams && typeof parsedIntent.imageParams === "object"
593
+ ? parsedIntent.imageParams
594
+ : {
595
+ mode: "full-prompt",
596
+ full_prompt: resolvedTextResponse,
597
+ };
598
+ mediaTasks.push(this.generatePrimitive("image", imagePayload).then((res) => {
501
599
  finalImageUrl = res.image_url;
502
600
  }));
503
601
  }
@@ -507,8 +605,12 @@ Note: If "imageParams", "voiceArgs", "stateUpdate", or "triggerEvent" are not ne
507
605
  const normalizedVoiceArgs = parsedIntent.voiceArgs && typeof parsedIntent.voiceArgs === "object"
508
606
  ? parsedIntent.voiceArgs
509
607
  : {};
608
+ const textForVoice = typeof resolvedTextResponse === "string" &&
609
+ resolvedTextResponse.trim().length > 0
610
+ ? resolvedTextResponse
611
+ : "...";
510
612
  mediaTasks.push(this.generatePrimitive("voice", {
511
- text: parsedIntent.textResponse,
613
+ text: textForVoice,
512
614
  dynamicArgs: normalizedVoiceArgs,
513
615
  }).then((res) => {
514
616
  finalAudioUrl = res.audio_url;
@@ -519,11 +621,13 @@ Note: If "imageParams", "voiceArgs", "stateUpdate", or "triggerEvent" are not ne
519
621
  await Promise.all(mediaTasks);
520
622
  return {
521
623
  status: "success",
522
- textResponse: parsedIntent.textResponse || "...",
624
+ textResponse: resolvedTextResponse || "...",
523
625
  imageUrl: finalImageUrl,
524
626
  audioUrl: finalAudioUrl,
525
627
  durationSec: finalDurationSec,
526
628
  triggeredEvent: parsedIntent.triggerEvent || undefined,
629
+ stateUpdate: parsedIntent.stateUpdate,
630
+ userAnalysis: parsedIntent.userAnalysis,
527
631
  };
528
632
  }
529
633
  catch (error) {
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface LLMConfig {
2
- provider: 'minimax' | 'openai';
2
+ provider: 'minimax';
3
3
  apiKey: string;
4
4
  model: string;
5
5
  }
@@ -7,6 +7,8 @@ export interface CyberSoulClientConfig {
7
7
  characterKey: string;
8
8
  backendUrl: string;
9
9
  llmConfig: LLMConfig;
10
+ requestTimeoutMs?: number;
11
+ maxRetries?: number;
10
12
  }
11
13
  export declare enum InteractRequestType {
12
14
  AUTO = "auto",
@@ -54,12 +56,20 @@ export interface InteractResponse {
54
56
  durationMins?: number;
55
57
  outfitId?: string | null;
56
58
  };
59
+ stateUpdate?: DispatcherIntent['stateUpdate'];
60
+ userAnalysis?: DispatcherIntent['userAnalysis'];
57
61
  error?: string;
58
62
  }
59
63
  export interface DispatcherIntent {
60
64
  textResponse?: string;
61
65
  imageParams?: any;
62
66
  voiceArgs?: VoiceArgs | null;
67
+ userAnalysis?: {
68
+ newFactsLearned: {
69
+ category: "nickname" | "occupation" | "age" | "gender" | "hobby" | "trait" | "communicationStyle" | "boundary";
70
+ value: string;
71
+ }[];
72
+ };
63
73
  stateUpdate?: {
64
74
  temperatureDelta?: string | number;
65
75
  userNickname?: string;
@@ -122,6 +132,7 @@ export interface CharacterState {
122
132
  personality_traits?: string;
123
133
  interaction_boundaries?: string;
124
134
  communication_style?: string;
135
+ user_codex?: any;
125
136
  }
126
137
  export interface BaseLLMProvider {
127
138
  generate(messages: {
@@ -129,3 +140,56 @@ export interface BaseLLMProvider {
129
140
  content: string;
130
141
  }[], maxTokens?: number, temperature?: number): Promise<string>;
131
142
  }
143
+ export type ModelCustomConfigValueType = 'string' | 'stringArray' | 'number' | 'integer' | 'boolean' | 'enum';
144
+ export interface IModelCustomConfigField {
145
+ key: string;
146
+ label: string;
147
+ valueType: ModelCustomConfigValueType;
148
+ customerFacing?: boolean;
149
+ isFile?: boolean;
150
+ description?: string;
151
+ required?: boolean;
152
+ defaultValue?: string | number | boolean | string[];
153
+ minItems?: number;
154
+ maxItems?: number;
155
+ min?: number;
156
+ max?: number;
157
+ step?: number;
158
+ enumOptions?: string[];
159
+ options?: string[];
160
+ }
161
+ export interface IVoiceModel {
162
+ id: string;
163
+ name: string;
164
+ ttsProvider: string;
165
+ voiceConfigPayload: Record<string, unknown>;
166
+ dynamicParamPromptTemplate: string;
167
+ dynamicParams: Array<{
168
+ name: string;
169
+ description: string;
170
+ type: string;
171
+ required: boolean;
172
+ default?: unknown;
173
+ }>;
174
+ voiceOptions: Array<{
175
+ id: string;
176
+ name: string;
177
+ description?: string;
178
+ configPatch: Record<string, unknown>;
179
+ dynamicParamPromptTemplate?: string;
180
+ sampleUrl?: string;
181
+ }>;
182
+ voiceCustomConfigDefinition?: IModelCustomConfigField[];
183
+ isPublic: boolean;
184
+ pointsPerGeneration: number;
185
+ }
186
+ export interface ICharacterProfile {
187
+ id: string;
188
+ name: string;
189
+ voiceModelId?: string;
190
+ voiceModelOptionId?: string;
191
+ voiceCustomConfig?: Record<string, Record<string, unknown>>;
192
+ visualModelId?: string;
193
+ visualCustomConfig?: Record<string, Record<string, unknown>>;
194
+ [key: string]: unknown;
195
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@space3-npm/cybersoul-client",
3
- "version": "1.1.1",
3
+ "version": "1.1.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -17,7 +17,7 @@
17
17
  "scripts": {
18
18
  "build": "tsc",
19
19
  "prepare": "npm run build",
20
- "test": "echo \"Error: no test specified\" && exit 1"
20
+ "test": "node -e \"console.log('No tests defined yet')\""
21
21
  },
22
22
  "keywords": [
23
23
  "cybersoul",