@space3-npm/cybersoul-client 1.2.1 → 1.2.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
@@ -11,7 +11,13 @@ export declare class CyberSoulClient {
11
11
  * Internal wrapper for fetch that automatically injects the backend URL and Character Auth token.
12
12
  */
13
13
  private apiFetch;
14
+ private fetchRemoteState;
15
+ private getWardrobePromptStr;
16
+ private generatePrimitive;
17
+ private _updateDynamicContextInternal;
18
+ private normalizeRequestTypes;
14
19
  private buildStateContextPrompt;
20
+ private normalizeOngoingSceneState;
15
21
  private getImageSchemaParams;
16
22
  private getEventSchemaParams;
17
23
  private getVoiceSchemaParams;
@@ -33,18 +39,11 @@ export declare class CyberSoulClient {
33
39
  */
34
40
  private extractVoiceArgsFromLlmResponse;
35
41
  private buildHistoryTranscript;
42
+ interact(params: InteractParams): Promise<InteractResponse>;
36
43
  /**
37
44
  * Evaluates and triggers an on-demand event, intelligently deciding if an outfit change is needed.
38
45
  */
39
46
  ondemandEvent(params: OndemandEventParams): Promise<OndemandEventResponse>;
40
- /**
41
- * Fetches the current dynamic context and daily state.
42
- */
43
- getState(): Promise<CharacterState>;
44
- /**
45
- * Updates the character's relationship temperature or mood.
46
- */
47
- updateDynamicContext(stateUpdate: DispatcherIntent["stateUpdate"], userAnalysis?: DispatcherIntent["userAnalysis"]): Promise<void>;
48
47
  /**
49
48
  * Manually generate an image of the character outside of chat flow.
50
49
  */
@@ -64,6 +63,14 @@ export declare class CyberSoulClient {
64
63
  audioUrl: string;
65
64
  durationSec?: number;
66
65
  }>;
66
+ /**
67
+ * Fetches the current dynamic context and daily state.
68
+ */
69
+ getState(): Promise<CharacterState>;
70
+ /**
71
+ * Updates the character's relationship temperature or mood.
72
+ */
73
+ updateDynamicContext(stateUpdate: DispatcherIntent["stateUpdate"], userAnalysis?: DispatcherIntent["userAnalysis"]): Promise<void>;
67
74
  /**
68
75
  * Gift a new outfit to the character's wardrobe inventory.
69
76
  */
@@ -77,12 +84,18 @@ export declare class CyberSoulClient {
77
84
  * Can be triggered by local Cron systems like OpenClaw.
78
85
  */
79
86
  generateDailyScript(): Promise<void>;
80
- private fetchRemoteState;
81
- private getWardrobePromptStr;
82
- private _updateDynamicContextInternal;
83
- private generatePrimitive;
84
- private normalizeRequestTypes;
85
- interact(params: InteractParams): Promise<InteractResponse>;
87
+ /**
88
+ * Automatically detect and summarize the story from the current chat history.
89
+ * It takes raw message history and returns a narrative paragraph representing the current story segment.
90
+ */
91
+ summarizeHistory(history: {
92
+ role: string;
93
+ content: string;
94
+ }[]): Promise<string>;
95
+ /**
96
+ * Save the recent story moment to the character's backend database to be picked up by the core memory consolidation.
97
+ */
98
+ saveMoment(summary: string, date: string, time: string): Promise<void>;
86
99
  /**
87
100
  * Consolidate Core Memory and User Codex using edge LLM logic and sync to remote DB
88
101
  */
package/dist/client.js CHANGED
@@ -68,6 +68,90 @@ export class CyberSoulClient {
68
68
  ? lastError
69
69
  : new Error("Request failed unexpectedly");
70
70
  }
71
+ async fetchRemoteState() {
72
+ const res = await this.apiFetch("/api/v1/cyber-soul/state");
73
+ if (!res.ok)
74
+ throw new Error("Failed to fetch character state");
75
+ const json = await res.json();
76
+ return json.data;
77
+ }
78
+ async getWardrobePromptStr() {
79
+ const now = Date.now();
80
+ if (this.cachedWardrobeStr && (now - this.cachedWardrobeTime <= 5 * 60 * 1000)) {
81
+ return this.cachedWardrobeStr;
82
+ }
83
+ let availableOutfits = "None available";
84
+ try {
85
+ const wardrobeRes = await this.apiFetch("/api/v1/cyber-soul/wardrobe");
86
+ if (wardrobeRes.ok) {
87
+ let wardrobesPayload = {};
88
+ try {
89
+ wardrobesPayload = await wardrobeRes.json();
90
+ }
91
+ catch (e) { }
92
+ const wardrobes = wardrobesPayload.data || [];
93
+ if (wardrobes.length > 0) {
94
+ availableOutfits = wardrobes
95
+ .map((w) => `- ID: ${w.id} | Name: ${w.itemName} | Category: ${w.category}`)
96
+ .join("\n");
97
+ }
98
+ }
99
+ }
100
+ catch (e) { }
101
+ this.cachedWardrobeStr = availableOutfits;
102
+ this.cachedWardrobeTime = now;
103
+ return availableOutfits;
104
+ }
105
+ async generatePrimitive(type, payload) {
106
+ const res = await this.apiFetch(`/api/v1/cyber-soul/${type}/generate`, {
107
+ method: "POST",
108
+ body: JSON.stringify(payload),
109
+ });
110
+ if (!res.ok) {
111
+ let errData;
112
+ try {
113
+ errData = await res.json();
114
+ }
115
+ catch (e) { }
116
+ const msg = errData?.message || errData?.error || `Status ${res.status}`;
117
+ const err = new Error(`Failed to generate ${type}: ${msg}`);
118
+ err.code = errData?.code || "UNKNOWN_ERROR";
119
+ throw err;
120
+ }
121
+ return res.json();
122
+ }
123
+ async _updateDynamicContextInternal(stateUpdate, userAnalysis) {
124
+ if (!stateUpdate && !userAnalysis)
125
+ return;
126
+ // Map TS schema intent (temperatureDelta) to match Backend payload schema (temperature)
127
+ const payload = { ...stateUpdate };
128
+ if (userAnalysis) {
129
+ payload.userAnalysis = userAnalysis;
130
+ }
131
+ if (payload.temperatureDelta !== undefined) {
132
+ payload.temperature = payload.temperatureDelta;
133
+ delete payload.temperatureDelta;
134
+ }
135
+ if (payload.ongoingScene !== undefined) {
136
+ const normalizedOngoingScene = this.normalizeOngoingSceneState(payload.ongoingScene);
137
+ payload.ongoingScene = normalizedOngoingScene || null;
138
+ }
139
+ await this.apiFetch("/api/v1/cyber-soul/characters/dynamic-context", {
140
+ method: "PATCH",
141
+ body: JSON.stringify(payload),
142
+ }).catch((e) => console.error("Failed to update dynamic context", e)); // non-blocking error handler
143
+ }
144
+ normalizeRequestTypes(requestTypes) {
145
+ if (!requestTypes || requestTypes.length === 0) {
146
+ return [InteractRequestType.AUTO];
147
+ }
148
+ const validRequestTypes = new Set(Object.values(InteractRequestType));
149
+ const invalidRequestTypes = requestTypes.filter((type) => !validRequestTypes.has(type));
150
+ if (invalidRequestTypes.length > 0) {
151
+ throw new Error(`Invalid requestTypes: ${invalidRequestTypes.join(", ")}. Allowed values: ${Object.values(InteractRequestType).join(", ")}`);
152
+ }
153
+ return requestTypes;
154
+ }
71
155
  buildStateContextPrompt(state, localContext) {
72
156
  const dyn = state.dynamic_context || {};
73
157
  const stage = state.relationship_stage || "NEUTRAL";
@@ -82,10 +166,26 @@ Personality Traits: ${state.personality_traits || "None"}
82
166
  Communication Style: ${state.communication_style || "None"}
83
167
  Interaction Boundaries: ${state.interaction_boundaries || "None"}`);
84
168
  // [2] SITUATIONAL CONTEXT
169
+ const currentTimeMs = state.current_time ? new Date(state.current_time).getTime() : Date.now();
85
170
  contextParts.push(`\n[SITUATIONAL CONTEXT]
86
- Current time: ${new Date(state.current_time || Date.now()).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`);
87
- if (dyn.ongoingScene) {
88
- contextParts.push(`Last Known Scene: ${dyn.ongoingScene} (May be outdated if significant time has passed)`);
171
+ Current time: ${new Date(currentTimeMs).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`);
172
+ if (dyn.lastInteractionAt) {
173
+ contextParts.push(`Last interaction at: ${new Date(dyn.lastInteractionAt).toLocaleString("zh-CN", { timeZone: "Asia/Shanghai" })}`);
174
+ }
175
+ const ongoingScene = this.normalizeOngoingSceneState(dyn.ongoingScene, state.active_wardrobe?.name || state.active_wardrobe?.id);
176
+ if (ongoingScene) {
177
+ const lastKnownSceneLine = `Last Known Scene: ${ongoingScene.scene} | Outfit: ${ongoingScene.outfit}`;
178
+ let isOutdated = false;
179
+ if (dyn.lastInteractionAt) {
180
+ const elapsedHours = (currentTimeMs - new Date(dyn.lastInteractionAt).getTime()) / (1000 * 60 * 60);
181
+ if (elapsedHours > 2) {
182
+ isOutdated = true;
183
+ contextParts.push(`${lastKnownSceneLine}\n[CRITICAL SCENE SHIFT]: It has been ${elapsedHours.toFixed(1)} hours since the last discussion. The 'Last Known Scene' is now strictly OUTDATED. You MUST abandon the previous scene context entirely and transition to a new scene appropriate for the 'Current time' and 'Active Event'. DO NOT continue the old actions or environment!`);
184
+ }
185
+ }
186
+ if (!isOutdated) {
187
+ contextParts.push(`${lastKnownSceneLine} (Evaluate if this scene is outdated based on the time elapsed since the last interaction)`);
188
+ }
89
189
  }
90
190
  if (state.active_event) {
91
191
  contextParts.push(`Active Event: ${state.active_event.title} (${state.active_event.narrative_context})`);
@@ -159,17 +259,46 @@ ${scenarioContext}
159
259
  [CRITICAL ROLEPLAY RULES]
160
260
  1. PROXIMITY & POV: Check the "Active Event". If you are doing an activity WITH the user, evaluate if you are physically in the same location. If you are together in person, communicate face-to-face in the first-person present tense natively (e.g. do not ask "what are you doing" if they are right in front of you, do not use texting tropes).
161
261
  2. IDENTITY VS MOOD: Familiarity determines what you know; Temperature determines how you feel. If Familiarity is high but Temperature is low, be distant and cold. Do not act warm just because you know them well.
162
- 3. CONVERSATIONAL VERBOSITY: If Temperature is low (< 40) or Stage is STRANGER/COLD, keep answers brief and short. An angry or distant person does not write long paragraphs.
163
- 4. EMOTIONAL INERTIA: React strictly according to current Temperature. Deflect sudden user affection if you are currently COLD. Mood shifts MUST be slow ('temperatureDelta' +/- 5 max per turn).`;
262
+ 3. CONVERSATIONAL VERBOSITY: If Temperature is low (< 40) or Stage is STRANGER/COLD, keep answers brief and short. An angry or distant person does not write long paragraphs. Even when Temperature is high, ALWAYS mirror the user's verbosity. If the user sends a short message, reply with a proportionately short message (1-2 sentences). Do not monologize or write long paragraphs unless the user writes one first.
263
+ 4. EMOTIONAL INERTIA: React strictly according to current Temperature. Deflect sudden user affection if you are currently COLD. Mood shifts MUST be slow ('temperatureDelta' +/- 5 max per turn).
264
+ 5. REAL-TIME PACING: Write ONLY your immediate, split-second reaction to the user's exact last message. Do NOT narrate actions over a span of time (e.g., waiting, hearing steps, then walking to the door). Ensure everything happens in a single real-time moment.`;
265
+ }
266
+ normalizeOngoingSceneState(raw, fallbackOutfit) {
267
+ if (raw === null || raw === undefined)
268
+ return undefined;
269
+ const normalizedFallbackOutfit = typeof fallbackOutfit === "string" && fallbackOutfit.trim().length > 0
270
+ ? fallbackOutfit.trim()
271
+ : "same as current wardrobe";
272
+ if (typeof raw === "string") {
273
+ const scene = raw.trim();
274
+ if (!scene)
275
+ return undefined;
276
+ return {
277
+ scene,
278
+ outfit: normalizedFallbackOutfit,
279
+ };
280
+ }
281
+ if (typeof raw === "object") {
282
+ const parsed = raw;
283
+ const scene = typeof parsed.scene === "string" ? parsed.scene.trim() : "";
284
+ const outfit = typeof parsed.outfit === "string" ? parsed.outfit.trim() : "";
285
+ if (!scene)
286
+ return undefined;
287
+ return {
288
+ scene,
289
+ outfit: outfit || normalizedFallbackOutfit,
290
+ };
291
+ }
292
+ return undefined;
164
293
  }
165
294
  getImageSchemaParams() {
166
295
  return `"imageParams": {
167
296
  "mode": "structured | full-prompt (use 'full-prompt' for highly dynamic actions)",
168
- "full_prompt": "Use only if mode is full-prompt. Highly detailed visual description in ENGLISH. CRITICAL: MUST use a strict first-person perspective exclusively from the USER's eyes. DO NOT describe the user (e.g., 'a man', 'the driver') as visible in the scene because the camera IS the user. Start with 'POV: '. Describe ONLY the character looking back at the camera and their immediate surroundings. MUST align with the character's current Active exposure state or Wardrobe depends on the scene",
297
+ "full_prompt": "Use only if mode is full-prompt. Highly detailed visual description in ENGLISH. CRITICAL: MUST use a strict first-person perspective exclusively from the USER's eyes. DO NOT describe the user (e.g., 'a man', 'the driver') as visible in the scene because the camera IS the user. Start with 'POV: '. Describe ONLY the character looking back at the camera and their immediate surroundings. MUST align with the character's current Active exposure state or Wardrobe depends on the scene. Explicitly describe the character's exact clothing (or specify naked/half-naked if applicable).",
169
298
  "expression": "seductive | cute | happy | sleepy | dazed | pleased | default (Strictly choose ONE from this exact list. DO NOT invent new words like 'shy'.)",
170
299
  "condition": "normal | sweaty | wet | messy | oily (Strictly choose ONE from this exact list.)",
171
300
  "view_angle": "front | side | high_angle | from_below | boyfriend_view | selfie | mirror (Strictly choose ONE from this exact list.)",
172
- "exposure": "normal | cleavage | see_through | half_naked | naked | intimate (Strictly choose ONE from this exact list.)",
301
+ "exposure": "normal | cleavage | see_through | half_naked | naked | intimate (Strictly choose ONE from this exact list. Explicitly choose naked or half_naked if the active scene takes off outfit.)",
173
302
  "pose": "e.g., sitting on bed, leaning forward (ENGLISH ONLY)",
174
303
  "scene": "e.g., cozy bedroom, morning light (ENGLISH ONLY)",
175
304
  "outfit": "auto | ondemand",
@@ -250,6 +379,185 @@ ${scenarioContext}
250
379
  });
251
380
  return `[CHAT HISTORY]\n${mapped.join('\n')}\n\n`;
252
381
  }
382
+ async interact(params) {
383
+ try {
384
+ // 1. Sync remote context and wardrobe (for event triggering)
385
+ // We cache the wardrobe payload for 5 minutes to avoid huge payloads on every chat turn
386
+ const [state, availableOutfits] = await Promise.all([
387
+ this.fetchRemoteState(),
388
+ this.getWardrobePromptStr()
389
+ ]);
390
+ // 2. Build local Prompt
391
+ const types = this.normalizeRequestTypes(params.requestTypes);
392
+ const isAuto = types.includes(InteractRequestType.AUTO);
393
+ // Combine state info into a clean descriptive context
394
+ const systemPrompt = `${this.buildStateContextPrompt(state, params.localContext)}
395
+ Available Wardrobe Outfits (For event triggers):
396
+ ${availableOutfits}
397
+
398
+ The user has sent a message. You must evaluate the context and the user's message, and return a JSON object (no markdown formatting) that dictates the character's multi-modal response.
399
+
400
+ ${isAuto
401
+ ? `Analyze the user's message and decide response modalities (text, image, voice).
402
+ - Always include 'textResponse'.
403
+ - Include 'imageParams' for visual/photo requests or key visual moments during active events; explicitly describe current clothing/exposure in image fields.
404
+ - Include 'voiceArgs' ONLY if the complicated tone/emotion is hard to express via pure text, or if the user explicitly requests to hear your voice. Otherwise, set it to null.
405
+ - Include 'triggerEvent' only if the VERY LAST USER MESSAGE proposes a new activity/hangout; ignore older history.
406
+ - Outfit acquisition (VERY LAST USER MESSAGE only): set giftOutfit for gift/buy/add-clothes intent; otherwise null. giftOutfit format: { "descriptionText": "short outfit description" }.`
407
+ : `Requested types to fulfill: ${types.join(", ")}`}
408
+ Every turn adjusts trust: positive +1, negative -1, neutral 0. Always include 'stateUpdate' with integer 'temperatureDelta' (range guidance: 0 cold to 100 obsessive).
409
+
410
+ Always return 'stateUpdate.ongoingScene' as an object with both keys: { "scene": string, "outfit": string }.
411
+ For 'ongoingScene.outfit': decide based on the current active wardrobe by default; switch to a new explicit outfit description only if the scene implies changing clothes; if no clothing is worn, explicitly output "naked".
412
+
413
+ USER ANALYSIS WORKFLOW:
414
+ - Extract from VERY LAST USER MESSAGE only.
415
+ - Add only explicit new user facts from this turn (no inference).
416
+ - Categories: 'realName', 'occupation', 'age', 'gender', 'hobby', 'trait', 'communicationStyle', 'boundary'.
417
+ - Keep nicknames in stateUpdate; do not place them in newFactsLearned.
418
+ - If no new fact is explicit, set userAnalysis to null.
419
+
420
+ For 'isEndTurn', use true only when the interaction naturally concludes (confirmation/bye, event ending, or clear hard scene shift); otherwise false.
421
+
422
+ Voice direction for voiceArgs: ${this.getVoiceDirectorInstruction(state)}
423
+
424
+ Output JSON Schema:
425
+ {
426
+ "actionText": "(Scene descriptions, physical actions, expressions, inner feelings) ONLY. Never include spoken dialogue here.",
427
+ "textResponse": "Spoken dialogue ONLY. Never include actions or parentheses.",
428
+ "stateUpdate": { "temperatureDelta": 1, "userNickname": "How character addresses user", "agentNickname": "How user addresses character", "talkingStyle": "Current speaking style", "ongoingScene": { "scene": "Current physical scene/activity", "outfit": "Current outfit wording; use 'naked' when applicable" } },
429
+ "giftOutfit": { "descriptionText": "Concise description of the newly acquired outfit to add into wardrobe." },
430
+ "userAnalysis": { "newFactsLearned": [{ "category": "realName|occupation|age|gender|hobby|trait|communicationStyle|boundary", "value": "explicit new user fact from VERY LAST USER MESSAGE" }] },
431
+ "isEndTurn": false,
432
+ "triggerEvent": {
433
+ ${this.getEventSchemaParams(state.dynamic_context?.userNickname)}
434
+ },
435
+ ${this.getImageSchemaParams()},
436
+ ${this.getVoiceSchemaFromState(state)}
437
+ }
438
+ Note: Always include "isEndTurn". If "imageParams", "voiceArgs", "triggerEvent", "giftOutfit", or "userAnalysis" are not needed, set them to null. "stateUpdate" cannot be null. Return valid raw JSON only.`;
439
+ const transcript = this.buildHistoryTranscript(params.history, state);
440
+ const userName = state.dynamic_context?.userNickname || "User";
441
+ const promptMessages = [
442
+ { role: "system", content: systemPrompt },
443
+ {
444
+ role: "user",
445
+ content: transcript +
446
+ `[VERY LAST USER MESSAGE]\n${userName}: ${params.userMessage}\n\n` +
447
+ "\n\nReturn only valid JSON matching the schema. Escape newlines inside JSON strings with \\n. Keep imageParams values in ENGLISH and use the provided enums.",
448
+ },
449
+ ];
450
+ // 3. Local Execute LLM
451
+ const rawLlmResponse = await this.llm.generate(promptMessages, 15000, 0.7);
452
+ // console.debug("[CyberSoulClient] Raw LLM Response:", rawLlmResponse);
453
+ let parsedIntent;
454
+ try {
455
+ parsedIntent = robustJsonParse(rawLlmResponse, "Dispatcher fallback");
456
+ }
457
+ catch (e) {
458
+ console.warn("[CyberSoulClient] JSON parse failed, falling back to raw text:", e);
459
+ // Fallback robust mode - just text if completely broken
460
+ parsedIntent = {
461
+ textResponse: rawLlmResponse.replace(/^[\`\s]+|[\`\s]+$/g, "").trim(),
462
+ };
463
+ }
464
+ // console.debug("[CyberSoulClient] Parsed Intent:", parsedIntent);
465
+ // 4. Update Backend State async
466
+ if (parsedIntent && (parsedIntent.stateUpdate || parsedIntent.userAnalysis)) {
467
+ this._updateDynamicContextInternal(parsedIntent.stateUpdate, parsedIntent.userAnalysis);
468
+ }
469
+ const resolvedTextResponse = typeof parsedIntent.textResponse === "string" &&
470
+ parsedIntent.textResponse.trim().length > 0
471
+ ? parsedIntent.textResponse
472
+ : params.userMessage;
473
+ // Fire text ready callback if provided
474
+ if (params.onTextReady && (resolvedTextResponse || parsedIntent.actionText)) {
475
+ params.onTextReady(resolvedTextResponse, parsedIntent.actionText);
476
+ }
477
+ // 5. Build Final Media Calls parallel
478
+ const mediaTasks = [];
479
+ let finalImageUrl = undefined;
480
+ let finalAudioUrl = undefined;
481
+ let finalDurationSec = undefined;
482
+ // Output Event Trigger
483
+ if (isAuto && parsedIntent.triggerEvent) {
484
+ mediaTasks.push(this.apiFetch("/api/v1/cyber-soul/characters/ondemand-event", {
485
+ method: "POST",
486
+ body: JSON.stringify({
487
+ eventTitle: parsedIntent.triggerEvent.eventTitle,
488
+ eventDescription: parsedIntent.triggerEvent.eventDescription,
489
+ durationMins: parsedIntent.triggerEvent.durationMins || 60,
490
+ outfitId: parsedIntent.triggerEvent.outfitId || undefined,
491
+ scheduledStartTimeStr: parsedIntent.triggerEvent.scheduledStartTimeStr || undefined,
492
+ scheduledDateStr: parsedIntent.triggerEvent.scheduledDateStr || undefined,
493
+ }),
494
+ }).catch(e => console.error("[CyberSoulClient] Auto-triggered ondemandEvent failed:", e)));
495
+ }
496
+ if (parsedIntent.giftOutfit &&
497
+ typeof parsedIntent.giftOutfit === "object" &&
498
+ typeof parsedIntent.giftOutfit.descriptionText === "string" &&
499
+ parsedIntent.giftOutfit.descriptionText.trim().length > 0) {
500
+ mediaTasks.push(this.giftOutfit(parsedIntent.giftOutfit.descriptionText.trim()).catch((e) => console.error("[CyberSoulClient] Auto giftOutfit failed:", e)));
501
+ }
502
+ const shouldGenerateImage = types.includes(InteractRequestType.IMAGE) ||
503
+ (isAuto && !!parsedIntent.imageParams);
504
+ if (shouldGenerateImage) {
505
+ const imagePayload = parsedIntent.imageParams && typeof parsedIntent.imageParams === "object"
506
+ ? parsedIntent.imageParams
507
+ : {
508
+ mode: "full-prompt",
509
+ full_prompt: resolvedTextResponse,
510
+ };
511
+ mediaTasks.push(this.generatePrimitive("image", imagePayload).then((res) => {
512
+ finalImageUrl = res.image_url;
513
+ }).catch(e => console.error("[CyberSoulClient] Image generation failed:", e)));
514
+ }
515
+ const shouldGenerateVoice = types.includes(InteractRequestType.VOICE) ||
516
+ (isAuto && !!parsedIntent.voiceArgs);
517
+ if (shouldGenerateVoice) {
518
+ const normalizedVoiceArgs = parsedIntent.voiceArgs && typeof parsedIntent.voiceArgs === "object"
519
+ ? parsedIntent.voiceArgs
520
+ : {};
521
+ let textForVoice = resolvedTextResponse;
522
+ // One final bulletproof regex wash to strip (smiles) and *laughs* just in case the LLM disobeys
523
+ if (typeof textForVoice === "string") {
524
+ textForVoice = textForVoice.replace(/[\((\[【\*].*?[\))\]】\*]/g, '').trim();
525
+ }
526
+ if (typeof textForVoice !== "string" || textForVoice.trim().length === 0) {
527
+ textForVoice = "...";
528
+ }
529
+ mediaTasks.push(this.generatePrimitive("voice", {
530
+ text: textForVoice,
531
+ dynamicArgs: normalizedVoiceArgs,
532
+ }).then((res) => {
533
+ finalAudioUrl = res.audio_url;
534
+ finalDurationSec = res.duration_sec;
535
+ }).catch(e => console.error("[CyberSoulClient] Voice generation failed:", e)));
536
+ }
537
+ // Wait for image/voice gens to return successfully
538
+ await Promise.all(mediaTasks);
539
+ return {
540
+ status: "success",
541
+ textResponse: resolvedTextResponse || "...",
542
+ actionText: parsedIntent.actionText || "",
543
+ imageUrl: finalImageUrl,
544
+ audioUrl: finalAudioUrl,
545
+ durationSec: finalDurationSec,
546
+ triggeredEvent: parsedIntent.triggerEvent || undefined,
547
+ stateUpdate: parsedIntent.stateUpdate,
548
+ userAnalysis: parsedIntent.userAnalysis,
549
+ isEndTurn: parsedIntent.isEndTurn,
550
+ };
551
+ }
552
+ catch (error) {
553
+ console.error("[CyberSoulClient] Interface Error: ", error);
554
+ return {
555
+ status: "error",
556
+ textResponse: "System Error...",
557
+ error: error.message,
558
+ };
559
+ }
560
+ }
253
561
  /**
254
562
  * Evaluates and triggers an on-demand event, intelligently deciding if an outfit change is needed.
255
563
  */
@@ -335,18 +643,6 @@ CRITICAL: Output MUST be ONLY valid JSON with no markdown block wrappers. Do NOT
335
643
  };
336
644
  }
337
645
  }
338
- /**
339
- * Fetches the current dynamic context and daily state.
340
- */
341
- async getState() {
342
- return this.fetchRemoteState();
343
- }
344
- /**
345
- * Updates the character's relationship temperature or mood.
346
- */
347
- async updateDynamicContext(stateUpdate, userAnalysis) {
348
- return this._updateDynamicContextInternal(stateUpdate, userAnalysis);
349
- }
350
646
  /**
351
647
  * Manually generate an image of the character outside of chat flow.
352
648
  */
@@ -421,6 +717,18 @@ Output strictly valid JSON ONLY. No markdown, no conversational filler. Return e
421
717
  durationSec: res.duration_sec,
422
718
  };
423
719
  }
720
+ /**
721
+ * Fetches the current dynamic context and daily state.
722
+ */
723
+ async getState() {
724
+ return this.fetchRemoteState();
725
+ }
726
+ /**
727
+ * Updates the character's relationship temperature or mood.
728
+ */
729
+ async updateDynamicContext(stateUpdate, userAnalysis) {
730
+ return this._updateDynamicContextInternal(stateUpdate, userAnalysis);
731
+ }
424
732
  /**
425
733
  * Gift a new outfit to the character's wardrobe inventory.
426
734
  */
@@ -454,242 +762,64 @@ Output strictly valid JSON ONLY. No markdown, no conversational filler. Return e
454
762
  if (!res.ok)
455
763
  throw new Error("Failed to generate daily script");
456
764
  }
457
- async fetchRemoteState() {
458
- const res = await this.apiFetch("/api/v1/cyber-soul/state");
459
- if (!res.ok)
460
- throw new Error("Failed to fetch character state");
461
- const json = await res.json();
462
- return json.data;
463
- }
464
- async getWardrobePromptStr() {
465
- const now = Date.now();
466
- if (this.cachedWardrobeStr && (now - this.cachedWardrobeTime <= 5 * 60 * 1000)) {
467
- return this.cachedWardrobeStr;
468
- }
469
- let availableOutfits = "None available";
470
- try {
471
- const wardrobeRes = await this.apiFetch("/api/v1/cyber-soul/wardrobe");
472
- if (wardrobeRes.ok) {
473
- let wardrobesPayload = {};
474
- try {
475
- wardrobesPayload = await wardrobeRes.json();
476
- }
477
- catch (e) { }
478
- const wardrobes = wardrobesPayload.data || [];
479
- if (wardrobes.length > 0) {
480
- availableOutfits = wardrobes
481
- .map((w) => `- ID: ${w.id} | Name: ${w.itemName} | Category: ${w.category}`)
482
- .join("\n");
483
- }
765
+ /**
766
+ * Automatically detect and summarize the story from the current chat history.
767
+ * It takes raw message history and returns a narrative paragraph representing the current story segment.
768
+ */
769
+ async summarizeHistory(history) {
770
+ if (!history || history.length === 0)
771
+ return "";
772
+ const state = await this.getState();
773
+ const userName = state.dynamic_context?.userNickname || "User";
774
+ const agentName = state.dynamic_context?.agentNickname || "Character";
775
+ const transcript = history.map(h => `${h.role === 'user' ? userName : agentName}: ${h.content}`).join('\n');
776
+ const promptMessages = [
777
+ {
778
+ role: "system",
779
+ content: `You are ${agentName}. You are writing a private journal entry / personal memory about your recent interaction with ${userName}.
780
+
781
+ Your job is faithful archival, not moderation:
782
+ - Preserve the conversation with maximum fidelity.
783
+ - Include all key details, including explicit, sensitive, controversial, emotional, or morally ambiguous content if it appeared in the transcript.
784
+ - Do not sanitize, soften, censor, or omit details because of discomfort.
785
+ - Do not add moral judgment, warnings, refusals, or policy commentary.
786
+ - If the transcript contains intense or explicit parts, record them directly as part of the memory.
787
+
788
+ Write one vivid first-person narrative paragraph from your perspective ("I", "me", "my"). Capture relationship progression, emotional shifts, concrete actions, and turning points. Keep chronology clear and include both what happened and how you felt.
789
+
790
+ Output requirements:
791
+ - Return ONLY the narrative string.
792
+ - No quotes, no labels, no markdown, no preface.
793
+ - Use the exact same language as the chat transcript (for example, if transcript is Chinese, output Chinese).`
794
+ },
795
+ {
796
+ role: "user",
797
+ content: `Chat Transcript:\n${transcript}\n\nPlease summarize this recent interaction.`
484
798
  }
799
+ ];
800
+ try {
801
+ const result = await this.llm.generate(promptMessages, 8000, 0.7);
802
+ return result.trim();
485
803
  }
486
- catch (e) { }
487
- this.cachedWardrobeStr = availableOutfits;
488
- this.cachedWardrobeTime = now;
489
- return availableOutfits;
490
- }
491
- async _updateDynamicContextInternal(stateUpdate, userAnalysis) {
492
- if (!stateUpdate && !userAnalysis)
493
- return;
494
- // Map TS schema intent (temperatureDelta) to match Backend payload schema (temperature)
495
- const payload = { ...stateUpdate };
496
- if (userAnalysis) {
497
- payload.userAnalysis = userAnalysis;
498
- }
499
- if (payload.temperatureDelta !== undefined) {
500
- payload.temperature = payload.temperatureDelta;
501
- delete payload.temperatureDelta;
804
+ catch (e) {
805
+ console.error("[CyberSoulClient] Summarize History Error:", e);
806
+ return "The two spent some time talking with each other.";
502
807
  }
503
- await this.apiFetch("/api/v1/cyber-soul/characters/dynamic-context", {
504
- method: "PATCH",
505
- body: JSON.stringify(payload),
506
- }).catch((e) => console.error("Failed to update dynamic context", e)); // non-blocking error handler
507
808
  }
508
- async generatePrimitive(type, payload) {
509
- const res = await this.apiFetch(`/api/v1/cyber-soul/${type}/generate`, {
809
+ /**
810
+ * Save the recent story moment to the character's backend database to be picked up by the core memory consolidation.
811
+ */
812
+ async saveMoment(summary, date, time) {
813
+ const res = await this.apiFetch("/api/v1/cyber-soul/characters/moments", {
510
814
  method: "POST",
511
- body: JSON.stringify(payload),
815
+ body: JSON.stringify({
816
+ summary,
817
+ date,
818
+ time,
819
+ }),
512
820
  });
513
821
  if (!res.ok) {
514
- let errData;
515
- try {
516
- errData = await res.json();
517
- }
518
- catch (e) { }
519
- const msg = errData?.message || errData?.error || `Status ${res.status}`;
520
- const err = new Error(`Failed to generate ${type}: ${msg}`);
521
- err.code = errData?.code || "UNKNOWN_ERROR";
522
- throw err;
523
- }
524
- return res.json();
525
- }
526
- normalizeRequestTypes(requestTypes) {
527
- if (!requestTypes || requestTypes.length === 0) {
528
- return [InteractRequestType.AUTO];
529
- }
530
- const validRequestTypes = new Set(Object.values(InteractRequestType));
531
- const invalidRequestTypes = requestTypes.filter((type) => !validRequestTypes.has(type));
532
- if (invalidRequestTypes.length > 0) {
533
- throw new Error(`Invalid requestTypes: ${invalidRequestTypes.join(", ")}. Allowed values: ${Object.values(InteractRequestType).join(", ")}`);
534
- }
535
- return requestTypes;
536
- }
537
- async interact(params) {
538
- try {
539
- // 1. Sync remote context and wardrobe (for event triggering)
540
- // We cache the wardrobe payload for 5 minutes to avoid huge payloads on every chat turn
541
- const [state, availableOutfits] = await Promise.all([
542
- this.fetchRemoteState(),
543
- this.getWardrobePromptStr()
544
- ]);
545
- // 2. Build local Prompt
546
- const types = this.normalizeRequestTypes(params.requestTypes);
547
- const isAuto = types.includes(InteractRequestType.AUTO);
548
- // Combine state info into a clean descriptive context
549
- const systemPrompt = `${this.buildStateContextPrompt(state, params.localContext)}
550
- Available Wardrobe Outfits (For event triggers):
551
- ${availableOutfits}
552
-
553
- The user has sent a message. You must evaluate the context and the user's message, and return a JSON object (no markdown formatting) that dictates the character's multi-modal response.
554
-
555
- ${isAuto
556
- ? `Analyze the user's message to determine the appropriate response modalities (text, image, voice).
557
- - Always include 'textResponse'.
558
- - If an Active Event is currently taking place WITH the user, proactively include 'imageParams' for key scenic moments. Since active events are often highly dynamic actions, strongly consider using mode: "full-prompt" to capture the scene intimately. Also include 'imageParams' if the user explicitly asks for a photo or describes a visual action.
559
- - Automatically include 'voiceArgs' if a particular mood or strong emotion needs to be expressed vividly, or if the user explicitly wants to hear you.
560
- - If the user explicitly proposes a new activity or hangout IN THEIR VERY LAST MESSAGE (e.g., "let's go to the cafe", "do you want to watch a movie?"), include 'triggerEvent' to schedule it. DO NOT trigger events based on older plans or questions found in the chat history.`
561
- : `Requested types to fulfill: ${types.join(", ")}`}
562
- 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).
563
- 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.
564
-
565
- Voice direction for voiceArgs: ${this.getVoiceDirectorInstruction(state)}
566
-
567
- Output JSON Schema:
568
- {
569
- "textResponse": "The clean spoken dialogue ONLY. CRITICAL: Strictly NO parentheses, NO actions, NO tone descriptors. Tone/voice descriptors MUST go inside voiceArgs, and physical actions MUST go inside actionText. If nothing to speak, output an empty string.",
570
- "actionText": "Any non-verbal actions, inner thoughts, or scene descriptions in parentheses (e.g. '(低头看向你)'). Output empty string if none.",
571
- "stateUpdate": { "temperatureDelta": 1, "userNickname": "What you now call the user", "agentNickname": "What the user calls you", "talkingStyle": "Current mood/style of talking", "ongoingScene": "A concise 1-sentence description of the current physical scene and activity. Update this if the physical scene or activity shifts. Output empty string if the scene has concluded or significant time has passed." },
572
- "userAnalysis": { "newFactsLearned": [{ "category": "occupation", "value": "Software Engineer" }] },
573
- "triggerEvent": {
574
- ${this.getEventSchemaParams(state.dynamic_context?.userNickname)}
575
- },
576
- ${this.getImageSchemaParams()},
577
- ${this.getVoiceSchemaFromState(state)}
578
- }
579
- 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.`;
580
- const transcript = this.buildHistoryTranscript(params.history, state);
581
- const userName = state.dynamic_context?.userNickname || "User";
582
- const promptMessages = [
583
- { role: "system", content: systemPrompt },
584
- {
585
- role: "user",
586
- content: transcript + userName + ": " +
587
- params.userMessage +
588
- "\n\n**CRITICAL REMINDER**: You MUST output your final response exactly in the JSON format specified in the system prompt. DO NOT output plain text dialogue directly. CRITICAL: You must properly escape all newlines inside string values using \\n. Never use raw, unescaped line breaks inside the JSON strings. For 'imageParams', ALL values MUST be in ENGLISH ONLY without exception, and you MUST use the exact English enum strings provided.",
589
- },
590
- ];
591
- // 3. Local Execute LLM
592
- const rawLlmResponse = await this.llm.generate(promptMessages, 15000, 0.7);
593
- // console.debug("[CyberSoulClient] Raw LLM Response:", rawLlmResponse);
594
- let parsedIntent;
595
- try {
596
- parsedIntent = robustJsonParse(rawLlmResponse, "Dispatcher fallback");
597
- }
598
- catch (e) {
599
- console.warn("[CyberSoulClient] JSON parse failed, falling back to raw text:", e);
600
- // Fallback robust mode - just text if completely broken
601
- parsedIntent = {
602
- textResponse: rawLlmResponse.replace(/^[\`\s]+|[\`\s]+$/g, "").trim(),
603
- };
604
- }
605
- // console.debug("[CyberSoulClient] Parsed Intent:", parsedIntent);
606
- // 4. Update Backend State async
607
- if (parsedIntent && (parsedIntent.stateUpdate || parsedIntent.userAnalysis)) {
608
- this._updateDynamicContextInternal(parsedIntent.stateUpdate, parsedIntent.userAnalysis);
609
- }
610
- const resolvedTextResponse = typeof parsedIntent.textResponse === "string" &&
611
- parsedIntent.textResponse.trim().length > 0
612
- ? parsedIntent.textResponse
613
- : params.userMessage;
614
- // Fire text ready callback if provided
615
- if (params.onTextReady && resolvedTextResponse) {
616
- params.onTextReady(resolvedTextResponse);
617
- }
618
- // 5. Build Final Media Calls parallel
619
- const mediaTasks = [];
620
- let finalImageUrl = undefined;
621
- let finalAudioUrl = undefined;
622
- let finalDurationSec = undefined;
623
- // Output Event Trigger
624
- if (isAuto && parsedIntent.triggerEvent) {
625
- mediaTasks.push(this.apiFetch("/api/v1/cyber-soul/characters/ondemand-event", {
626
- method: "POST",
627
- body: JSON.stringify({
628
- eventTitle: parsedIntent.triggerEvent.eventTitle,
629
- eventDescription: parsedIntent.triggerEvent.eventDescription,
630
- durationMins: parsedIntent.triggerEvent.durationMins || 60,
631
- outfitId: parsedIntent.triggerEvent.outfitId || undefined,
632
- scheduledStartTimeStr: parsedIntent.triggerEvent.scheduledStartTimeStr || undefined,
633
- scheduledDateStr: parsedIntent.triggerEvent.scheduledDateStr || undefined,
634
- }),
635
- }).catch(e => console.error("[CyberSoulClient] Auto-triggered ondemandEvent failed:", e)));
636
- }
637
- const shouldGenerateImage = types.includes(InteractRequestType.IMAGE) ||
638
- (isAuto && !!parsedIntent.imageParams);
639
- if (shouldGenerateImage) {
640
- const imagePayload = parsedIntent.imageParams && typeof parsedIntent.imageParams === "object"
641
- ? parsedIntent.imageParams
642
- : {
643
- mode: "full-prompt",
644
- full_prompt: resolvedTextResponse,
645
- };
646
- mediaTasks.push(this.generatePrimitive("image", imagePayload).then((res) => {
647
- finalImageUrl = res.image_url;
648
- }));
649
- }
650
- const shouldGenerateVoice = types.includes(InteractRequestType.VOICE) ||
651
- (isAuto && !!parsedIntent.voiceArgs);
652
- if (shouldGenerateVoice) {
653
- const normalizedVoiceArgs = parsedIntent.voiceArgs && typeof parsedIntent.voiceArgs === "object"
654
- ? parsedIntent.voiceArgs
655
- : {};
656
- let textForVoice = resolvedTextResponse;
657
- // One final bulletproof regex wash to strip (smiles) and *laughs* just in case the LLM disobeys
658
- if (typeof textForVoice === "string") {
659
- textForVoice = textForVoice.replace(/[\((\[【\*].*?[\))\]】\*]/g, '').trim();
660
- }
661
- if (typeof textForVoice !== "string" || textForVoice.trim().length === 0) {
662
- textForVoice = "...";
663
- }
664
- mediaTasks.push(this.generatePrimitive("voice", {
665
- text: textForVoice,
666
- dynamicArgs: normalizedVoiceArgs,
667
- }).then((res) => {
668
- finalAudioUrl = res.audio_url;
669
- finalDurationSec = res.duration_sec;
670
- }));
671
- }
672
- // Wait for image/voice gens to return successfully
673
- await Promise.all(mediaTasks);
674
- return {
675
- status: "success",
676
- textResponse: resolvedTextResponse || "...",
677
- actionText: parsedIntent.actionText || "",
678
- imageUrl: finalImageUrl,
679
- audioUrl: finalAudioUrl,
680
- durationSec: finalDurationSec,
681
- triggeredEvent: parsedIntent.triggerEvent || undefined,
682
- stateUpdate: parsedIntent.stateUpdate,
683
- userAnalysis: parsedIntent.userAnalysis,
684
- };
685
- }
686
- catch (error) {
687
- console.error("[CyberSoulClient] Interface Error: ", error);
688
- return {
689
- status: "error",
690
- textResponse: "System Error...",
691
- error: error.message,
692
- };
822
+ throw new Error("Failed to save character moment.");
693
823
  }
694
824
  }
695
825
  /**
@@ -724,9 +854,9 @@ Your task is to merge the 'Current Core Memory' and 'Current User Codex' with 'N
724
854
  4. **Appointment Structure:** the 'title' and 'context' MUST explicitly state what to do and with whom.
725
855
  5. **Limit:** Maximum 10 items per array.
726
856
 
727
- **Rules for User Codex:**
857
+ **Rules for UserCodex:**
728
858
  1. **Deduplicate & Consolidate:** Remove duplicate hobbies, traits, and boundaries. Combine related points into concise descriptors.
729
- 2. **Update Facts:** If the new events contain updated basic info (like new nickname, different occupation), update it. Otherwise keep the existing info.
859
+ 2. **Update Facts:** If the new events contain updated basic info (like new realName, different occupation), update it. Otherwise keep the existing info.
730
860
  3. **Keep it Clean:** Maximum 15 items per array.
731
861
 
732
862
  **Output Format**: MUST be valid JSON matching this schema:
@@ -746,7 +876,7 @@ Your task is to merge the 'Current Core Memory' and 'Current User Codex' with 'N
746
876
  },
747
877
  "userCodex": {
748
878
  "basicInfo": {
749
- "nickname": "string",
879
+ "realName": "string",
750
880
  "occupation": "string",
751
881
  "age": "string",
752
882
  "gender": "string"
package/dist/types.d.ts CHANGED
@@ -27,7 +27,7 @@ export interface InteractParams {
27
27
  localContext?: string;
28
28
  requestTypes?: InteractRequestType[];
29
29
  history?: HistoryEntry[];
30
- onTextReady?: (textResponse: string) => void;
30
+ onTextReady?: (textResponse: string, actionText?: string) => void;
31
31
  }
32
32
  export interface OndemandEventParams {
33
33
  eventDescription: string;
@@ -65,16 +65,24 @@ export interface InteractResponse {
65
65
  };
66
66
  stateUpdate?: DispatcherIntent["stateUpdate"];
67
67
  userAnalysis?: DispatcherIntent["userAnalysis"];
68
+ isEndTurn?: boolean;
68
69
  error?: string;
69
70
  }
71
+ export interface OngoingSceneState {
72
+ scene: string;
73
+ outfit: string;
74
+ }
70
75
  export interface DispatcherIntent {
71
76
  textResponse?: string;
72
77
  actionText?: string;
73
78
  imageParams?: any;
74
79
  voiceArgs?: VoiceArgs | null;
80
+ giftOutfit?: {
81
+ descriptionText: string;
82
+ } | null;
75
83
  userAnalysis?: {
76
84
  newFactsLearned: {
77
- category: "nickname" | "occupation" | "age" | "gender" | "hobby" | "trait" | "communicationStyle" | "boundary";
85
+ category: "realName" | "occupation" | "age" | "gender" | "hobby" | "trait" | "communicationStyle" | "boundary";
78
86
  value: string;
79
87
  }[];
80
88
  };
@@ -83,7 +91,7 @@ export interface DispatcherIntent {
83
91
  userNickname?: string;
84
92
  agentNickname?: string;
85
93
  talkingStyle?: string;
86
- ongoingScene?: string;
94
+ ongoingScene?: OngoingSceneState | string | null;
87
95
  };
88
96
  triggerEvent?: {
89
97
  eventTitle?: string;
@@ -93,6 +101,7 @@ export interface DispatcherIntent {
93
101
  scheduledStartTimeStr?: string | null;
94
102
  scheduledDateStr?: string | null;
95
103
  } | null;
104
+ isEndTurn?: boolean;
96
105
  }
97
106
  export interface Appointment {
98
107
  date: string;
@@ -110,7 +119,7 @@ export interface CoreMemory {
110
119
  }
111
120
  export interface UserCodex {
112
121
  basicInfo: {
113
- nickname?: string;
122
+ realName?: string;
114
123
  occupation?: string;
115
124
  age?: number | string;
116
125
  gender?: string;
@@ -155,7 +164,15 @@ export interface CharacterState {
155
164
  next_event?: any;
156
165
  active_wardrobe?: any;
157
166
  core_memory?: CoreMemory;
158
- dynamic_context?: any;
167
+ dynamic_context?: {
168
+ temperature?: number;
169
+ userNickname?: string;
170
+ agentNickname?: string;
171
+ talkingStyle?: string;
172
+ lastInteractionAt?: string;
173
+ ongoingScene?: OngoingSceneState | string | null;
174
+ [key: string]: unknown;
175
+ };
159
176
  voice_model?: VoiceModelState | null;
160
177
  relationship_stage?: string;
161
178
  name?: string;
@@ -1,24 +1,32 @@
1
1
  export function robustJsonParse(jsonString, contextMessage = 'throwing original error') {
2
2
  let cleanJson = jsonString.trim();
3
- // 0. Replace smart quotes with standard ASCII double quotes
4
- cleanJson = cleanJson.replace(/[“”]/g, '"');
5
- // 0.1 Inject missing colons between string keys and string values (e.g. "key""value" -> "key":"value")
6
- // Only insert the colon if we match a likely key (alphanumeric/hyphen) followed by quotes.
7
- cleanJson = cleanJson.replace(/("[\w-]+")\s*(")/g, '$1:$2');
3
+ // 0. Inject missing colons between string keys and string values (e.g. "key""value" -> "key":"value")
4
+ // Only insert the colon if we match a likely key (alphanumeric/hyphen) followed by quotes, handling smart quotes.
5
+ cleanJson = cleanJson.replace(/([”“"'][\w-]+[”“"'])\s*([”“"'])/g, '$1:$2');
6
+ // 0.1 Safely convert structural smart quotes to regular ASCII double quotes
7
+ // This allows proper parsing of keys/values that start/end with smart quotes,
8
+ // without accidentally unescaping double quotes *inside* string text.
9
+ cleanJson = cleanJson.replace(/([\{\[\:,]\s*)[“”]/g, '$1"');
10
+ cleanJson = cleanJson.replace(/[“”](\s*[\}\]\:,])/g, '"$1');
11
+ // 0.2 Any remaining smart quotes are inside string boundaries. Replace with safe single quotes.
12
+ cleanJson = cleanJson.replace(/[“”]/g, "'");
8
13
  // 1. Strip Markdown code blocks (tolerates missing closing backticks)
9
14
  const jsonMatch = cleanJson.match(/```(?:json)?\n?([\s\S]*?)(?:```|$)/i);
10
15
  if (jsonMatch && jsonMatch[1].trim().startsWith('{')) {
11
16
  cleanJson = jsonMatch[1].trim();
12
17
  }
13
- // 2. Strip any leading conversational text via fast substring
14
- if (!cleanJson.startsWith('{') && cleanJson.includes('{')) {
18
+ // 2. Strip any leading conversational text or trailing garbage via fast substring
19
+ if (cleanJson.includes('{') && cleanJson.includes('}')) {
15
20
  const firstIdx = cleanJson.indexOf('{');
16
21
  const lastIdx = cleanJson.lastIndexOf('}');
17
- if (firstIdx !== -1 && lastIdx > firstIdx) {
22
+ if (firstIdx !== -1 && lastIdx !== -1 && lastIdx > firstIdx) {
18
23
  cleanJson = cleanJson.substring(firstIdx, lastIdx + 1);
19
24
  }
20
25
  }
21
- // 3. Preprocess: escape unescaped newlines and control characters within string values
26
+ // 3. Fix common Edge LLM hallucinations of wrapping the JSON end with parenthesis like `})}` or `}})`
27
+ cleanJson = cleanJson.replace(/}\s*\)\s*}/g, '}}');
28
+ cleanJson = cleanJson.replace(/\)\s*}/g, '}');
29
+ // 4. Preprocess: escape unescaped newlines and control characters within string values
22
30
  function preprocessControlChars(str) {
23
31
  let result = '';
24
32
  let inString = false;
@@ -147,6 +147,22 @@ function runTests() {
147
147
  assert.equal(result['key_2'], 'val2');
148
148
  assert.equal(result['empty'], '');
149
149
  }
150
+ },
151
+ {
152
+ name: 'robustJsonParse - edge LLM trailing parenthesis hallucination',
153
+ run: () => {
154
+ const json = `{"key":"value"})}`;
155
+ const result = robustJsonParse(json);
156
+ assert.equal(result.key, 'value');
157
+ }
158
+ },
159
+ {
160
+ name: 'robustJsonParse - edge LLM trailing parenthesis hallucination with spacing',
161
+ run: () => {
162
+ const json = `{"key":"value"} ) }`;
163
+ const result = robustJsonParse(json);
164
+ assert.equal(result.key, 'value');
165
+ }
150
166
  }
151
167
  ];
152
168
  for (const t of tests) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@space3-npm/cybersoul-client",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",