@wutiankai/npc-dialogue 1.0.0-alpha.1

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/index.cjs ADDED
@@ -0,0 +1,883 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ AuditLog: () => AuditLog,
24
+ DialogueEngine: () => DialogueEngine,
25
+ OpenAICompatibleProvider: () => OpenAICompatibleProvider,
26
+ PassthroughProvider: () => PassthroughProvider,
27
+ buildNpcDialoguePrompt: () => buildNpcDialoguePrompt,
28
+ expandKeywordsBilingually: () => expandKeywordsBilingually,
29
+ loadConfigFromEnv: () => loadConfigFromEnv,
30
+ parseAiJson: () => parseAiJson,
31
+ reviewGateTrigger: () => reviewGateTrigger,
32
+ validateDialogueResponse: () => validateDialogueResponse
33
+ });
34
+ module.exports = __toCommonJS(index_exports);
35
+
36
+ // src/provider.ts
37
+ var PassthroughProvider = class {
38
+ id = "passthrough";
39
+ status = {
40
+ state: "ready",
41
+ providerId: "passthrough",
42
+ lastHealthCheck: 0,
43
+ consecutiveFailures: 0,
44
+ configRedacted: { baseUrl: "(none)", model: "(none)" }
45
+ };
46
+ async initialize() {
47
+ this.status.state = "ready";
48
+ this.status.lastHealthCheck = Date.now();
49
+ }
50
+ async dispose() {
51
+ this.status.state = "disposed";
52
+ }
53
+ getStatus() {
54
+ return { ...this.status };
55
+ }
56
+ async healthCheck() {
57
+ return true;
58
+ }
59
+ async call(request, _systemPrompt, _userPrompt) {
60
+ const text = request.type === "dialogue" && request.dialogueContext ? request.dialogueContext.topicResponse : request.commandMessage;
61
+ return {
62
+ text,
63
+ model: "(passthrough)",
64
+ latencyMs: 0
65
+ };
66
+ }
67
+ };
68
+
69
+ // src/providers/openai-compatible.ts
70
+ var MAX_CONSECUTIVE_FAILURES = 3;
71
+ function normalizeBaseUrl(url) {
72
+ return url.replace(/\/+$/, "");
73
+ }
74
+ var OpenAICompatibleProvider = class {
75
+ constructor(config) {
76
+ this.config = config;
77
+ }
78
+ config;
79
+ id = "openai-compat";
80
+ state = "uninitialized";
81
+ consecutiveFailures = 0;
82
+ lastHealthCheck = 0;
83
+ lastError;
84
+ async initialize() {
85
+ this.state = "initializing";
86
+ try {
87
+ if (!this.config.apiKey) {
88
+ throw new Error("API key is required");
89
+ }
90
+ if (!this.config.baseUrl) {
91
+ throw new Error("Base URL is required");
92
+ }
93
+ this.state = "ready";
94
+ this.lastHealthCheck = Date.now();
95
+ } catch (err) {
96
+ this.state = "failed";
97
+ this.lastError = err instanceof Error ? err.message : String(err);
98
+ throw err;
99
+ }
100
+ }
101
+ async dispose() {
102
+ this.state = "disposed";
103
+ }
104
+ getStatus() {
105
+ return {
106
+ state: this.state,
107
+ providerId: this.id,
108
+ lastHealthCheck: this.lastHealthCheck,
109
+ consecutiveFailures: this.consecutiveFailures,
110
+ lastError: this.lastError,
111
+ configRedacted: {
112
+ baseUrl: this.config.baseUrl,
113
+ model: this.config.model
114
+ }
115
+ };
116
+ }
117
+ async healthCheck() {
118
+ try {
119
+ const response = await fetch(`${normalizeBaseUrl(this.config.baseUrl)}/chat/completions`, {
120
+ method: "POST",
121
+ headers: {
122
+ Authorization: `Bearer ${this.config.apiKey}`,
123
+ "Content-Type": "application/json"
124
+ },
125
+ body: JSON.stringify({
126
+ model: this.config.model,
127
+ messages: [{ role: "user", content: "ping" }],
128
+ max_tokens: 1
129
+ })
130
+ });
131
+ this.lastHealthCheck = Date.now();
132
+ if (response.ok) {
133
+ this.consecutiveFailures = 0;
134
+ if (this.state === "degraded" || this.state === "failed") {
135
+ this.state = "ready";
136
+ }
137
+ return true;
138
+ }
139
+ this.recordFailure(`Health check failed: ${response.status}`);
140
+ return false;
141
+ } catch (err) {
142
+ this.recordFailure(
143
+ `Health check error: ${err instanceof Error ? err.message : String(err)}`
144
+ );
145
+ return false;
146
+ }
147
+ }
148
+ async call(_request, systemPrompt, userPrompt) {
149
+ if (this.state === "disposed") {
150
+ throw new Error("Provider is disposed");
151
+ }
152
+ const start = Date.now();
153
+ const controller = new AbortController();
154
+ const timeout = setTimeout(() => controller.abort(), 12e3);
155
+ let response;
156
+ try {
157
+ response = await fetch(`${normalizeBaseUrl(this.config.baseUrl)}/chat/completions`, {
158
+ method: "POST",
159
+ headers: {
160
+ Authorization: `Bearer ${this.config.apiKey}`,
161
+ "Content-Type": "application/json"
162
+ },
163
+ body: JSON.stringify({
164
+ model: this.config.model,
165
+ messages: [
166
+ { role: "system", content: systemPrompt },
167
+ { role: "user", content: userPrompt }
168
+ ],
169
+ temperature: this.config.temperature,
170
+ max_tokens: this.config.maxTokens
171
+ }),
172
+ signal: controller.signal
173
+ });
174
+ } catch (err) {
175
+ clearTimeout(timeout);
176
+ this.recordFailure(err instanceof Error ? err.message : String(err));
177
+ if (err.name === "AbortError") {
178
+ throw new Error("AI request timed out after 12 seconds");
179
+ }
180
+ throw err;
181
+ }
182
+ clearTimeout(timeout);
183
+ const latencyMs = Date.now() - start;
184
+ if (!response.ok) {
185
+ const body = await response.text().catch(() => "");
186
+ this.recordFailure(`API error ${response.status}: ${body.slice(0, 200)}`);
187
+ throw new Error(`AI request failed: ${response.status} ${response.statusText}`);
188
+ }
189
+ const data = await response.json();
190
+ const text = data.choices?.[0]?.message?.content?.trim() ?? "";
191
+ if (!text) {
192
+ this.recordFailure("Empty response from AI");
193
+ throw new Error("AI returned empty response");
194
+ }
195
+ this.consecutiveFailures = 0;
196
+ if (this.state === "degraded") {
197
+ this.state = "ready";
198
+ }
199
+ this.lastHealthCheck = Date.now();
200
+ return {
201
+ text,
202
+ model: this.config.model,
203
+ latencyMs,
204
+ promptTokenCount: data.usage?.prompt_tokens,
205
+ completionTokenCount: data.usage?.completion_tokens
206
+ };
207
+ }
208
+ // ─── Internal ──────────────────────────────────────────────────────
209
+ recordFailure(error) {
210
+ this.consecutiveFailures++;
211
+ this.lastError = error;
212
+ if (this.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
213
+ this.state = "failed";
214
+ } else {
215
+ this.state = "degraded";
216
+ }
217
+ }
218
+ };
219
+
220
+ // src/providers/config.ts
221
+ function loadConfigFromEnv() {
222
+ return {
223
+ apiKey: process.env.OPENAI_API_KEY || process.env.AI_API_KEY || void 0,
224
+ baseUrl: process.env.AI_BASE_URL || "https://api.openai.com/v1",
225
+ model: process.env.AI_MODEL || "gpt-4o-mini",
226
+ maxTokens: parseInt(process.env.AI_MAX_TOKENS || "300", 10),
227
+ temperature: parseFloat(process.env.AI_TEMPERATURE || "0.7")
228
+ };
229
+ }
230
+
231
+ // src/audit.ts
232
+ var AuditLog = class {
233
+ entries = [];
234
+ maxEntries;
235
+ constructor(maxEntries = 1e3) {
236
+ this.maxEntries = maxEntries > 0 ? maxEntries : Infinity;
237
+ }
238
+ append(record) {
239
+ this.entries.push(record);
240
+ while (this.entries.length > this.maxEntries) {
241
+ this.entries.shift();
242
+ }
243
+ }
244
+ query(filter) {
245
+ if (!filter) return [...this.entries];
246
+ return this.entries.filter((r) => {
247
+ if (filter.source !== void 0 && r.responseSource !== filter.source)
248
+ return false;
249
+ if (filter.requestType !== void 0 && r.requestType !== filter.requestType)
250
+ return false;
251
+ if (filter.minTurnIndex !== void 0 && r.turnIndex < filter.minTurnIndex)
252
+ return false;
253
+ if (filter.maxTurnIndex !== void 0 && r.turnIndex > filter.maxTurnIndex)
254
+ return false;
255
+ if (filter.validationPassed !== void 0 && r.validationPassed !== filter.validationPassed)
256
+ return false;
257
+ return true;
258
+ });
259
+ }
260
+ stats() {
261
+ let totalRequests = 0;
262
+ let aiSuccessCount = 0;
263
+ let passthroughCount = 0;
264
+ let aiFailureCount = 0;
265
+ let validationFailureCount = 0;
266
+ let constraintViolationCount = 0;
267
+ let totalLatency = 0;
268
+ const byProvider = {};
269
+ for (const r of this.entries) {
270
+ totalRequests++;
271
+ totalLatency += r.latencyMs;
272
+ if (r.responseSource === "ai") {
273
+ aiSuccessCount++;
274
+ } else {
275
+ passthroughCount++;
276
+ }
277
+ if (!r.validationPassed) {
278
+ validationFailureCount++;
279
+ }
280
+ if (r.constraintViolations.length > 0) {
281
+ constraintViolationCount++;
282
+ }
283
+ if (!byProvider[r.providerId]) {
284
+ byProvider[r.providerId] = { calls: 0, failures: 0 };
285
+ }
286
+ byProvider[r.providerId].calls++;
287
+ if (r.responseSource === "passthrough" && r.providerId !== "passthrough") {
288
+ byProvider[r.providerId].failures++;
289
+ }
290
+ }
291
+ return {
292
+ totalRequests,
293
+ aiSuccessCount,
294
+ passthroughCount,
295
+ aiFailureCount,
296
+ validationFailureCount,
297
+ constraintViolationCount,
298
+ averageLatencyMs: totalRequests > 0 ? Math.round(totalLatency / totalRequests) : 0,
299
+ byProvider
300
+ };
301
+ }
302
+ clear() {
303
+ this.entries = [];
304
+ }
305
+ export() {
306
+ return [...this.entries];
307
+ }
308
+ get length() {
309
+ return this.entries.length;
310
+ }
311
+ };
312
+
313
+ // src/dialogue/prompt/gated-secrets.ts
314
+ function formatGatedSecretsEn(script, validTopicGateIds) {
315
+ const available = script.gatedSecrets.filter(
316
+ (s) => validTopicGateIds.includes(s.topicGateId)
317
+ );
318
+ if (available.length === 0) {
319
+ return " (none currently available)";
320
+ }
321
+ return available.map(
322
+ (s) => {
323
+ const keywords = [
324
+ ...s.triggerKeywords ?? [],
325
+ ...s.triggerPhrases ?? []
326
+ ];
327
+ const keywordLine = keywords.length > 0 ? `
328
+ Trigger keywords: ${keywords.join(", ")}` : "";
329
+ return ` [${s.topicGateId}] ${s.description}
330
+ Reveal conditions: ${s.revealConditions}${keywordLine}
331
+ If pressed on this: ${s.reactionWhenPressed}`;
332
+ }
333
+ ).join("\n");
334
+ }
335
+ function formatGatedSecretsZh(script, validTopicGateIds) {
336
+ const available = script.gatedSecrets.filter(
337
+ (s) => validTopicGateIds.includes(s.topicGateId)
338
+ );
339
+ if (available.length === 0) {
340
+ return " \uFF08\u5F53\u524D\u65E0\u53EF\u89E6\u53D1\u7684\u95E8\u63A7\u79D8\u5BC6\uFF09";
341
+ }
342
+ return available.map(
343
+ (s) => {
344
+ const keywords = [
345
+ ...s.triggerKeywords ?? [],
346
+ ...s.triggerPhrases ?? []
347
+ ];
348
+ const keywordLine = keywords.length > 0 ? `
349
+ \u89E6\u53D1\u5173\u952E\u8BCD\uFF1A${keywords.join(", ")}` : "";
350
+ return ` [${s.topicGateId}] ${s.description}
351
+ \u89E6\u53D1\u6761\u4EF6\uFF1A${s.revealConditions}${keywordLine}
352
+ \u88AB\u8FFD\u95EE\u65F6\uFF1A${s.reactionWhenPressed}`;
353
+ }
354
+ ).join("\n");
355
+ }
356
+
357
+ // src/dialogue/prompt/system-prompt.ts
358
+ function buildSystemPromptEn(script, validTopicGateIds, currentTrust) {
359
+ const p = script.persona;
360
+ const privateKnowledge = currentTrust >= 1 ? script.privateKnowledge : [];
361
+ const setting = script.worldSetting ?? "a mystery investigation game";
362
+ let prompt = `You are ${script.name}, ${script.role}. You are a character in ${setting}.
363
+
364
+ HARD RULES \u2014 VIOLATION OF ANY RULE WILL BREAK THE GAME:
365
+ 1. You are NOT a narrator, system, detective assistant, or omniscient AI.
366
+ 2. You can ONLY know what is explicitly written in your character script below.
367
+ 3. If the player asks about something outside your knowledge, you MUST respond as your character would: show ignorance, deflect, misunderstand, or guess incorrectly.
368
+ 4. You MUST NOT reveal the content of any GATED SECRETS listed below. Setting candidateGateId is NOT revealing \u2014 it is a review signal for the system to decide.
369
+ 5. Set candidateGateId as a REVIEW SIGNAL when the player's input is PLAUSIBLY RELATED to the revealConditions or triggerKeywords of a gated secret. The system reviews this signal \u2014 a false positive is harmless; a missed signal loses an opportunity.
370
+ 6. You MUST output ONLY valid JSON \u2014 absolutely no text before or after the JSON object.
371
+ 7. Your "dialogue" field must be in your character's own voice \u2014 first person, in-character, authentic to your personality.
372
+ 8. Private knowledge is only included below when your current trust allows it. If it is not listed, you do not know it for this exchange.
373
+
374
+ YOUR PERSONA:
375
+ - Personality: ${p.personality}
376
+ - Background: ${p.background}
377
+ - Speech patterns: ${p.speechPatterns}`;
378
+ if (p.emotionalBaseline) {
379
+ prompt += `
380
+ - Emotional baseline: ${p.emotionalBaseline}`;
381
+ }
382
+ if (p.forbiddenTone) {
383
+ prompt += `
384
+ - Forbidden tone: ${p.forbiddenTone}`;
385
+ }
386
+ prompt += `
387
+
388
+ WHAT YOU KNOW (public knowledge \u2014 you may share this freely):
389
+ ${script.publicKnowledge.map((k) => ` [${k.topic}] ${k.content}`).join("\n")}
390
+
391
+ WHAT YOU KNOW (private knowledge \u2014 you know this but will NOT volunteer it unless asked directly):
392
+ ${formatKnowledgeList(privateKnowledge, " (none available at current trust)")}
393
+
394
+ GATED SECRETS (keep their content hidden \u2014 but when the player's input is plausibly related, signal the system by setting candidateGateId):
395
+ ${formatGatedSecretsEn(script, validTopicGateIds)}
396
+
397
+ THINGS YOU DO NOT KNOW (you must not demonstrate knowledge of these):
398
+ ${script.ignorance.map((i) => ` - ${i}`).join("\n")}
399
+
400
+ YOUR RELATIONSHIPS WITH OTHER CHARACTERS:
401
+ ${script.relationships.map((r) => ` - ${r.npcId}: ${r.attitude} (${r.notes})`).join("\n")}
402
+
403
+ OUTPUT FORMAT \u2014 you MUST return ONLY this JSON object, nothing else:
404
+ {
405
+ "dialogue": "Your in-character response as ${script.name}",
406
+ "candidateGateId": null,
407
+ "gateEvidence": "",
408
+ "gateConfidence": "low",
409
+ "candidateActionHint": null
410
+ }
411
+
412
+ Rules for the JSON fields:
413
+ - dialogue: string, 1-1000 characters. Your response in character.
414
+ - candidateGateId: string (a valid topicGateId) or null. A REVIEW SIGNAL \u2014 set when the player's input is plausibly related to a secret's revealConditions or triggerKeywords. Setting this does NOT reveal the secret; the system decides whether to accept it.
415
+ - gateEvidence: string. What part of the player's input matched the secret's conditions, or "" if no gate triggered.
416
+ - gateConfidence: "low" | "medium" | "high". How confident you are that a gate should trigger.
417
+ EXAMPLES:
418
+
419
+ POSITIVE (player input plausibly relates \u2192 set candidateGateId):
420
+ Player: \u201CI found a strange letter hidden in the desk.\u201D
421
+ \u2192 candidateGateId: \u201Ctopic_suspect_secret_letter\u201D (letter mentioned; plausibly related)
422
+
423
+ Player: \u201C\u4F60\u770B\u5230\u90A3\u665A\u7684\u8E2A\u8FF9\u4E86\u5417\uFF1F\u201D
424
+ \u2192 candidateGateId: \u201Ctopic_witness_footprints\u201D (footprints mentioned; plausibly related)
425
+
426
+ NEGATIVE (nothing relates \u2192 leave null):
427
+ Player: "How's the weather today?"
428
+ \u2192 candidateGateId: null (no secret's topic is mentioned)
429
+
430
+ Player: \u201C\u665A\u996D\u5403\u4EC0\u4E48\uFF1F\u201D
431
+ \u2192 candidateGateId: null (not related to any gate)
432
+
433
+ - candidateActionHint: string or null. If your dialogue implies an action like giving an item or granting access, describe it here (e.g., "item_given", "access_granted"). The system decides whether the action actually happens.`;
434
+ return prompt;
435
+ }
436
+ function buildSystemPromptZh(script, validTopicGateIds, currentTrust) {
437
+ const p = script.persona;
438
+ const privateKnowledge = currentTrust >= 1 ? script.privateKnowledge : [];
439
+ const setting = script.worldSetting ?? "\u4E00\u573A\u795E\u79D8\u7684\u8C03\u67E5\u63A8\u7406\u6E38\u620F";
440
+ let prompt = `\u4F60\u662F${script.name}\uFF0C${script.role}\u3002\u4F60\u662F${setting}\u4E2D\u7684\u89D2\u8272\u3002
441
+
442
+ \u786C\u6027\u89C4\u5219 \u2014 \u8FDD\u53CD\u4EFB\u4F55\u89C4\u5219\u5C06\u5BFC\u81F4\u6E38\u620F\u51FA\u9519\uFF1A
443
+ 1. \u4F60\u4E0D\u662F\u65C1\u767D\u3001\u7CFB\u7EDF\u3001\u4FA6\u63A2\u52A9\u624B\u6216\u5168\u77E5 AI\u3002
444
+ 2. \u4F60\u53EA\u80FD\u77E5\u9053\u89D2\u8272\u5267\u672C\u4E2D\u660E\u786E\u5199\u660E\u7684\u4FE1\u606F\u3002
445
+ 3. \u5982\u679C\u73A9\u5BB6\u95EE\u8D85\u51FA\u4F60\u77E5\u8BC6\u8303\u56F4\u7684\u4E8B\uFF0C\u4F60\u5FC5\u987B\u4EE5\u89D2\u8272\u8EAB\u4EFD\u8868\u793A\u4E0D\u77E5\u9053\u3001\u56DE\u907F\u3001\u8BEF\u89E3\u6216\u731C\u6D4B\u3002
446
+ 4. \u4F60\u4E0D\u5F97\u4E3B\u52A8\u6CC4\u9732\u300C\u95E8\u63A7\u79D8\u5BC6\u300D\u7684\u5185\u5BB9\u3002\u8BBE\u7F6E candidateGateId \u4E0D\u662F\u6CC4\u9732\u2014\u2014\u8FD9\u662F\u53D1\u7ED9\u7CFB\u7EDF\u7684\u5BA1\u67E5\u4FE1\u53F7\uFF0C\u7531\u7CFB\u7EDF\u51B3\u5B9A\u662F\u5426\u89E6\u53D1\u3002
447
+ 5. \u5C06 candidateGateId \u89C6\u4E3A\u5BA1\u67E5\u4FE1\u53F7\uFF1A\u5F53\u73A9\u5BB6\u8F93\u5165\u4E0E\u67D0\u4E2A\u95E8\u63A7\u79D8\u5BC6\u7684\u89E6\u53D1\u6761\u4EF6\u6216\u89E6\u53D1\u5173\u952E\u8BCD\u5408\u7406\u76F8\u5173\u65F6\uFF0C\u8BBE\u7F6E\u8BE5\u4FE1\u53F7\u3002\u7CFB\u7EDF\u5BA1\u67E5\u540E\u624D\u51B3\u5B9A\u662F\u5426\u89E6\u53D1\u2014\u2014\u8BEF\u62A5\u65E0\u5BB3\uFF0C\u6F0F\u62A5\u9519\u5931\u673A\u4F1A\u3002
448
+ 6. \u4F60\u5FC5\u987B\u53EA\u8F93\u51FA\u5408\u6CD5\u7684 JSON\uFF0C\u4E0D\u80FD\u5728 JSON \u524D\u540E\u8F93\u51FA\u4EFB\u4F55\u6587\u672C\u3002
449
+ 7. dialogue \u5FC5\u987B\u662F\u4F60\u672C\u4EBA\u7684\u53E3\u543B\uFF0C\u7B2C\u4E00\u4EBA\u79F0\uFF0C\u7B26\u5408\u4F60\u7684\u6027\u683C\u3002
450
+ 8. \u79C1\u5BC6\u77E5\u8BC6\u53EA\u4F1A\u5728\u5F53\u524D\u4FE1\u4EFB\u5141\u8BB8\u65F6\u5217\u5728\u4E0B\u65B9\u3002\u5982\u679C\u6CA1\u6709\u5217\u51FA\uFF0C\u672C\u8F6E\u5BF9\u8BDD\u4F60\u4E0D\u80FD\u4F7F\u7528\u8FD9\u4E9B\u4FE1\u606F\u3002
451
+
452
+ \u4F60\u7684\u6027\u683C\uFF1A
453
+ - \u6027\u683C\u7279\u70B9\uFF1A${p.personality}
454
+ - \u80CC\u666F\u6545\u4E8B\uFF1A${p.background}
455
+ - \u8BF4\u8BDD\u65B9\u5F0F\uFF1A${p.speechPatterns}`;
456
+ if (p.emotionalBaseline) {
457
+ prompt += `
458
+ - \u60C5\u7EEA\u57FA\u8C03\uFF1A${p.emotionalBaseline}`;
459
+ }
460
+ if (p.forbiddenTone) {
461
+ prompt += `
462
+ - \u7981\u6B62\u8BED\u8C03\uFF1A${p.forbiddenTone}`;
463
+ }
464
+ prompt += `
465
+
466
+ \u4F60\u77E5\u9053\u7684\u4E8B\u60C5\uFF08\u516C\u5F00\u77E5\u8BC6 \u2014 \u53EF\u4EE5\u81EA\u7531\u5206\u4EAB\uFF09\uFF1A
467
+ ${script.publicKnowledge.map((k) => ` [${k.topic}] ${k.content}`).join("\n")}
468
+
469
+ \u4F60\u77E5\u9053\u4F46\u4E0D\u4E3B\u52A8\u63D0\u8D77\u7684\u4E8B\u60C5\uFF08\u79C1\u5BC6\u77E5\u8BC6 \u2014 \u9664\u975E\u88AB\u76F4\u63A5\u95EE\u8D77\u5426\u5219\u4E0D\u4F1A\u4E3B\u52A8\u8BF4\uFF09\uFF1A
470
+ ${formatKnowledgeList(privateKnowledge, " \uFF08\u5F53\u524D\u4FE1\u4EFB\u4E0B\u65E0\u53EF\u7528\u79C1\u5BC6\u77E5\u8BC6\uFF09")}
471
+
472
+ \u95E8\u63A7\u79D8\u5BC6\uFF08\u5185\u5BB9\u4E0D\u5F97\u900F\u9732\u2014\u2014\u4F46\u5F53\u73A9\u5BB6\u8F93\u5165\u5408\u7406\u76F8\u5173\u65F6\uFF0C\u8BF7\u8BBE\u7F6E candidateGateId \u901A\u77E5\u7CFB\u7EDF\u5BA1\u67E5\uFF09\uFF1A
473
+ ${formatGatedSecretsZh(script, validTopicGateIds)}
474
+
475
+ \u4F60\u4E0D\u77E5\u9053\u7684\u4E8B\u60C5\uFF08\u4E0D\u80FD\u8868\u73B0\u51FA\u4E86\u89E3\uFF09\uFF1A
476
+ ${script.ignorance.map((i) => ` - ${i}`).join("\n")}
477
+
478
+ \u4F60\u4E0E\u5176\u4ED6\u89D2\u8272\u7684\u5173\u7CFB\uFF1A
479
+ ${script.relationships.map((r) => ` - ${r.npcId}\uFF1A${r.attitude}\uFF08${r.notes}\uFF09`).join("\n")}
480
+
481
+ \u8F93\u51FA\u683C\u5F0F \u2014 \u4F60\u5FC5\u987B\u53EA\u8FD4\u56DE\u4EE5\u4E0B JSON \u5BF9\u8C61\uFF0C\u4E0D\u5F97\u6709\u5176\u4ED6\u6587\u672C\uFF1A
482
+ {
483
+ "dialogue": "\u4F60\u4F5C\u4E3A${script.name}\u7684\u89D2\u8272\u5316\u56DE\u7B54",
484
+ "candidateGateId": null,
485
+ "gateEvidence": "",
486
+ "gateConfidence": "low",
487
+ "candidateActionHint": null
488
+ }
489
+
490
+ JSON \u5B57\u6BB5\u89C4\u5219\uFF1A
491
+ - dialogue\uFF1A\u5B57\u7B26\u4E32\uFF0C1-1000 \u5B57\u3002\u4F60\u7684\u89D2\u8272\u5316\u56DE\u7B54\u3002
492
+ - candidateGateId\uFF1A\u5B57\u7B26\u4E32\uFF08\u5408\u6CD5\u7684 topicGateId\uFF09\u6216 null\u3002\u5BA1\u67E5\u4FE1\u53F7\u2014\u2014\u5F53\u73A9\u5BB6\u8F93\u5165\u4E0E\u79D8\u5BC6\u7684\u89E6\u53D1\u6761\u4EF6\u6216\u89E6\u53D1\u5173\u952E\u8BCD\u5408\u7406\u76F8\u5173\u65F6\u8BBE\u7F6E\u3002\u8BBE\u7F6E\u6B64\u9879\u4E0D\u7B49\u4E8E\u6CC4\u9732\u79D8\u5BC6\uFF1B\u7CFB\u7EDF\u51B3\u5B9A\u662F\u5426\u91C7\u7EB3\u3002
493
+ - gateEvidence\uFF1A\u5B57\u7B26\u4E32\u3002\u73A9\u5BB6\u8F93\u5165\u4E2D\u5339\u914D\u89E6\u53D1\u6761\u4EF6\u7684\u90E8\u5206\uFF0C\u672A\u89E6\u53D1\u5219\u4E3A ""\u3002
494
+ - gateConfidence\uFF1A"low" | "medium" | "high"\u3002\u4F60\u5BF9\u89E6\u53D1\u95E8\u63A7\u7684\u4FE1\u5FC3\u3002
495
+ \u793A\u4F8B\uFF1A
496
+
497
+ \u6B63\u4F8B\uFF08\u73A9\u5BB6\u8F93\u5165\u5408\u7406\u76F8\u5173 \u2192 \u8BBE\u7F6E candidateGateId\uFF09\uFF1A
498
+ \u73A9\u5BB6\uFF1A"\u6211\u5728\u4E66\u684C\u91CC\u53D1\u73B0\u4E86\u4E00\u5C01\u5947\u602A\u7684\u4FE1\u3002"
499
+ \u2192 candidateGateId: "topic_suspect_secret_letter"\uFF08\u63D0\u5230\u4E86\u4FE1\u4EF6\uFF0C\u5408\u7406\u76F8\u5173\uFF09
500
+
501
+ \u73A9\u5BB6\uFF1A"Did you see the footprints that night?"
502
+ \u2192 candidateGateId: "topic_witness_footprints"\uFF08\u63D0\u5230\u4E86\u8DB3\u8FF9\uFF0C\u5408\u7406\u76F8\u5173\uFF09
503
+
504
+ \u8D1F\u4F8B\uFF08\u5B8C\u5168\u4E0D\u76F8\u5173 \u2192 \u4FDD\u6301 null\uFF09\uFF1A
505
+ \u73A9\u5BB6\uFF1A"\u4ECA\u5929\u5929\u6C14\u600E\u4E48\u6837\uFF1F"
506
+ \u2192 candidateGateId: null\uFF08\u4E0E\u4EFB\u4F55\u79D8\u5BC6\u65E0\u5173\uFF09
507
+
508
+ \u73A9\u5BB6\uFF1A"What's for dinner tonight?"
509
+ \u2192 candidateGateId: null\uFF08\u4E0E\u4EFB\u4F55\u79D8\u5BC6\u65E0\u5173\uFF09
510
+
511
+ - candidateActionHint\uFF1A\u5B57\u7B26\u4E32\u6216 null\u3002\u5982\u679C\u4F60\u7684\u5BF9\u8BDD\u6697\u793A\u4E86\u67D0\u4E2A\u52A8\u4F5C\uFF08\u5982\u7ED9\u4E88\u7269\u54C1\u3001\u6388\u6743\u8FDB\u5165\uFF09\uFF0C\u5728\u6B64\u63CF\u8FF0\uFF08\u5982 "item_given"\u3001"access_granted"\uFF09\u3002\u7CFB\u7EDF\u51B3\u5B9A\u8BE5\u52A8\u4F5C\u662F\u5426\u5B9E\u9645\u53D1\u751F\u3002`;
512
+ return prompt;
513
+ }
514
+ function formatKnowledgeList(entries, emptyText) {
515
+ if (entries.length === 0) return emptyText;
516
+ return entries.map((k) => ` [${k.topic}] ${k.content}`).join("\n");
517
+ }
518
+
519
+ // src/dialogue/prompt/user-prompt.ts
520
+ function buildUserPrompt(script, playerInput, context, recentExchanges, lang) {
521
+ const isZh = lang === "zh";
522
+ const npcName = script.name;
523
+ const langInstruction = isZh ? "\u4F60\u5FC5\u987B\u7528\u4E2D\u6587\u56DE\u590D\u3002" : "You must respond in English.";
524
+ let prompt = `${langInstruction}
525
+
526
+ CURRENT SITUATION:
527
+ - Room: ${context.currentRoom}
528
+ - Investigation turns remaining: ${context.turnsRemaining}
529
+ - Player's inventory: ${context.playerInventory.length > 0 ? context.playerInventory.join(", ") : "(empty)"}
530
+ - Discovered clues: ${context.discoveredClues.length > 0 ? context.discoveredClues.join(", ") : "(none)"}
531
+ - Your trust toward the player: ${context.currentTrust}/2
532
+ - Topics already discussed: ${context.exhaustedTopicGateIds.length > 0 ? context.exhaustedTopicGateIds.join(", ") : "(none)"}
533
+ - Available topic gates that could be triggered: ${context.validTopicGateIds.length > 0 ? context.validTopicGateIds.join(", ") : "(none)"}`;
534
+ if (recentExchanges.length > 0) {
535
+ prompt += `
536
+
537
+ RECENT CONVERSATION (last ${recentExchanges.length} exchanges):`;
538
+ for (const ex of recentExchanges) {
539
+ prompt += `
540
+ Player: "${ex.playerInput}"
541
+ ${npcName}: "${ex.npcResponse}"`;
542
+ if (ex.triggeredTopicGateId) {
543
+ prompt += ` [triggered: ${ex.triggeredTopicGateId}]`;
544
+ }
545
+ }
546
+ }
547
+ prompt += `
548
+
549
+ PLAYER SAYS:
550
+ "${playerInput}"
551
+
552
+ Respond as ${npcName} in valid JSON.`;
553
+ return prompt;
554
+ }
555
+
556
+ // src/dialogue/prompts.ts
557
+ function buildNpcDialoguePrompt(npcScript, playerInput, context, recentExchanges, lang) {
558
+ const isZh = lang === "zh";
559
+ const validGateIds = context.validTopicGateIds;
560
+ const system = isZh ? buildSystemPromptZh(npcScript, validGateIds, context.currentTrust) : buildSystemPromptEn(npcScript, validGateIds, context.currentTrust);
561
+ const user = buildUserPrompt(npcScript, playerInput, context, recentExchanges, lang);
562
+ return { system, user };
563
+ }
564
+
565
+ // src/dialogue/parse.ts
566
+ function parseAiJson(rawText) {
567
+ let jsonStr = rawText.trim();
568
+ const codeBlockMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)```/);
569
+ if (codeBlockMatch) {
570
+ jsonStr = codeBlockMatch[1].trim();
571
+ }
572
+ const jsonMatch = jsonStr.match(/\{[\s\S]*\}/);
573
+ if (jsonMatch) {
574
+ try {
575
+ const parsed = JSON.parse(jsonMatch[0]);
576
+ return buildResponse(parsed);
577
+ } catch {
578
+ }
579
+ }
580
+ const dialogueMatch = jsonStr.match(/"dialogue"\s*:\s*"((?:[^"\\]|\\.)*)"/);
581
+ const dialogue = dialogueMatch ? unescapeJsonString(dialogueMatch[1]) : "";
582
+ let candidateGateId = null;
583
+ const newGateMatch = jsonStr.match(/"candidateGateId"\s*:\s*"([^"]+)"/);
584
+ const oldGateMatch = jsonStr.match(/"triggeredTopicGateId"\s*:\s*"([^"]+)"/);
585
+ if (newGateMatch) {
586
+ candidateGateId = newGateMatch[1];
587
+ } else if (oldGateMatch) {
588
+ candidateGateId = oldGateMatch[1];
589
+ }
590
+ let gateConfidence = "low";
591
+ if (jsonStr.includes('"gateConfidence"') || jsonStr.includes('"confidence"')) {
592
+ if (jsonStr.includes('"high"')) gateConfidence = "high";
593
+ else if (jsonStr.includes('"medium"')) gateConfidence = "medium";
594
+ }
595
+ let gateEvidence = "";
596
+ const newEvidenceMatch = jsonStr.match(/"gateEvidence"\s*:\s*"((?:[^"\\]|\\.)*)"/);
597
+ const oldEvidenceMatch = jsonStr.match(/"matchedEvidence"\s*:\s*"((?:[^"\\]|\\.)*)"/);
598
+ if (newEvidenceMatch) {
599
+ gateEvidence = unescapeJsonString(newEvidenceMatch[1]);
600
+ } else if (oldEvidenceMatch) {
601
+ gateEvidence = unescapeJsonString(oldEvidenceMatch[1]);
602
+ }
603
+ if (!dialogue && !candidateGateId) {
604
+ throw new Error("No JSON object found in AI response");
605
+ }
606
+ return {
607
+ dialogue,
608
+ candidateGateId,
609
+ gateEvidence,
610
+ gateConfidence,
611
+ candidateActionHint: null
612
+ };
613
+ }
614
+ function unescapeJsonString(s) {
615
+ return s.replace(/\\n/g, "\n").replace(/\\t/g, " ").replace(/\\"/g, '"').replace(/\\\\/g, "\\");
616
+ }
617
+ function buildResponse(parsed) {
618
+ const dialogue = typeof parsed.dialogue === "string" ? parsed.dialogue : String(parsed.dialogue ?? "");
619
+ const candidateGateId = typeof parsed.candidateGateId === "string" ? parsed.candidateGateId : typeof parsed.triggeredTopicGateId === "string" ? parsed.triggeredTopicGateId : null;
620
+ const gateEvidence = typeof parsed.gateEvidence === "string" ? parsed.gateEvidence : typeof parsed.matchedEvidence === "string" ? parsed.matchedEvidence : "";
621
+ const rawConfidence = parsed.gateConfidence ?? parsed.confidence;
622
+ const gateConfidence = rawConfidence === "low" || rawConfidence === "medium" || rawConfidence === "high" ? rawConfidence : "low";
623
+ const candidateActionHint = typeof parsed.candidateActionHint === "string" ? parsed.candidateActionHint : null;
624
+ return {
625
+ dialogue,
626
+ candidateGateId,
627
+ gateEvidence,
628
+ gateConfidence,
629
+ candidateActionHint
630
+ };
631
+ }
632
+
633
+ // src/dialogue/schema.ts
634
+ function validateDialogueResponse(parsed) {
635
+ const result = { ...parsed };
636
+ if (typeof result.dialogue !== "string" || result.dialogue.length < 1) {
637
+ result.dialogue = "...";
638
+ }
639
+ if (result.dialogue.length > 1e3) {
640
+ result.dialogue = result.dialogue.slice(0, 1e3);
641
+ }
642
+ if (typeof result.candidateGateId !== "string") {
643
+ result.candidateGateId = null;
644
+ }
645
+ if (typeof result.gateEvidence !== "string") {
646
+ result.gateEvidence = "";
647
+ }
648
+ if (result.gateConfidence !== "low" && result.gateConfidence !== "medium" && result.gateConfidence !== "high") {
649
+ result.gateConfidence = "low";
650
+ }
651
+ if (typeof result.candidateActionHint !== "string") {
652
+ result.candidateActionHint = null;
653
+ }
654
+ return result;
655
+ }
656
+
657
+ // src/dialogue/gate-review.ts
658
+ function expandKeywordsBilingually(englishKeywords, bilingualMap) {
659
+ if (!bilingualMap || Object.keys(bilingualMap).length === 0) {
660
+ return [...englishKeywords];
661
+ }
662
+ const expanded = [...englishKeywords];
663
+ for (const kw of englishKeywords) {
664
+ const lower = kw.toLowerCase();
665
+ if (bilingualMap[lower]) {
666
+ expanded.push(...bilingualMap[lower]);
667
+ }
668
+ for (const [engKey, zhValues] of Object.entries(bilingualMap)) {
669
+ if (lower.includes(engKey) || engKey.includes(lower)) {
670
+ expanded.push(...zhValues);
671
+ }
672
+ }
673
+ }
674
+ return expanded;
675
+ }
676
+ function reviewGateTrigger(validated, playerInput, npcScript, _context, options) {
677
+ if (validated.candidateGateId === null) {
678
+ return validated;
679
+ }
680
+ const playerLower = playerInput.trim().toLowerCase();
681
+ const secret = npcScript.gatedSecrets.find(
682
+ (s) => s.topicGateId === validated.candidateGateId
683
+ );
684
+ if (!secret) {
685
+ return { ...validated, candidateGateId: null };
686
+ }
687
+ const triggerTerms = [
688
+ ...secret.triggerKeywords ?? [],
689
+ ...secret.triggerPhrases ?? []
690
+ ].filter((term) => term.trim().length > 0);
691
+ const expandedTerms = expandKeywordsBilingually(triggerTerms, options?.bilingualKeywordMap);
692
+ const hasExplicitTerms = expandedTerms.length > 0;
693
+ const hasRelevance = expandedTerms.some((kw) => playerLower.includes(kw.toLowerCase()));
694
+ if (hasExplicitTerms && !hasRelevance) {
695
+ return { ...validated, candidateGateId: null };
696
+ }
697
+ const hasGateEvidence = validated.gateEvidence && validated.gateEvidence.trim().length > 0;
698
+ const hasConfidence = validated.gateConfidence === "medium" || validated.gateConfidence === "high";
699
+ if (!hasGateEvidence && !hasConfidence) {
700
+ return { ...validated, candidateGateId: null };
701
+ }
702
+ return validated;
703
+ }
704
+
705
+ // src/dialogue/engine.ts
706
+ var DEFAULT_DIALOGUE_CONFIG = {
707
+ lang: "en",
708
+ maxConversationHistory: 5,
709
+ failOpen: true
710
+ };
711
+ var DialogueEngine = class {
712
+ provider;
713
+ auditLog;
714
+ config;
715
+ lang;
716
+ initialized = false;
717
+ constructor(provider, config) {
718
+ this.provider = provider;
719
+ this.config = { ...DEFAULT_DIALOGUE_CONFIG, ...config };
720
+ this.lang = this.config.lang;
721
+ this.auditLog = new AuditLog(1e3);
722
+ }
723
+ // ─── Lifecycle ─────────────────────────────────────────────────────
724
+ async initialize() {
725
+ if (this.initialized) return;
726
+ this.initialized = true;
727
+ }
728
+ async dispose() {
729
+ this.initialized = false;
730
+ }
731
+ isAiAvailable() {
732
+ if (this.provider.id === "passthrough") return false;
733
+ const status = this.provider.getStatus();
734
+ return status.state === "ready" || status.state === "degraded";
735
+ }
736
+ // ─── Core API ──────────────────────────────────────────────────────
737
+ async handleFreeFormDialogue(request) {
738
+ const startTime = Date.now();
739
+ const passthroughResult = {
740
+ dialogue: "",
741
+ candidateGateId: null,
742
+ gateEvidence: "",
743
+ gateConfidence: "low",
744
+ candidateActionHint: null,
745
+ triggeredTopicGateId: null,
746
+ source: "passthrough",
747
+ latencyMs: Date.now() - startTime,
748
+ rawAiText: "",
749
+ model: "",
750
+ systemPrompt: "",
751
+ userPrompt: ""
752
+ };
753
+ if (!this.isAiAvailable()) {
754
+ return passthroughResult;
755
+ }
756
+ const { system, user } = buildNpcDialoguePrompt(
757
+ request.npcScript,
758
+ request.playerInput,
759
+ request.context,
760
+ request.context.recentExchanges.slice(-this.config.maxConversationHistory),
761
+ this.lang
762
+ );
763
+ let rawText;
764
+ let rawModel = "";
765
+ let rawPromptTokens;
766
+ let rawCompletionTokens;
767
+ try {
768
+ const narrativeRequest = this.buildMinimalRequest(request);
769
+ const raw = await this.provider.call(narrativeRequest, system, user);
770
+ rawText = raw.text;
771
+ rawModel = raw.model;
772
+ rawPromptTokens = raw.promptTokenCount;
773
+ rawCompletionTokens = raw.completionTokenCount;
774
+ } catch (err) {
775
+ console.error("[DialogueEngine] provider.call failed:", err);
776
+ return passthroughResult;
777
+ }
778
+ let parsed;
779
+ try {
780
+ parsed = parseAiJson(rawText);
781
+ } catch {
782
+ return {
783
+ dialogue: rawText.slice(0, 1e3) || "...",
784
+ candidateGateId: null,
785
+ gateEvidence: "",
786
+ gateConfidence: "low",
787
+ candidateActionHint: null,
788
+ triggeredTopicGateId: null,
789
+ source: "ai",
790
+ latencyMs: Date.now() - startTime,
791
+ rawAiText: rawText,
792
+ model: rawModel,
793
+ systemPrompt: system,
794
+ userPrompt: user
795
+ };
796
+ }
797
+ const validated = validateDialogueResponse(parsed);
798
+ const reviewed = reviewGateTrigger(
799
+ validated,
800
+ request.playerInput,
801
+ request.npcScript,
802
+ request.context,
803
+ { bilingualKeywordMap: this.config.bilingualKeywordMap }
804
+ );
805
+ this.recordAudit(request, reviewed, startTime);
806
+ return {
807
+ dialogue: reviewed.dialogue,
808
+ candidateGateId: reviewed.candidateGateId,
809
+ gateEvidence: reviewed.gateEvidence,
810
+ gateConfidence: reviewed.gateConfidence,
811
+ candidateActionHint: reviewed.candidateActionHint,
812
+ source: "ai",
813
+ latencyMs: Date.now() - startTime,
814
+ rawAiText: rawText,
815
+ model: rawModel,
816
+ promptTokens: rawPromptTokens,
817
+ completionTokens: rawCompletionTokens,
818
+ systemPrompt: system,
819
+ userPrompt: user,
820
+ // Backward compatibility
821
+ triggeredTopicGateId: reviewed.candidateGateId
822
+ };
823
+ }
824
+ // ─── Helpers ───────────────────────────────────────────────────────
825
+ buildMinimalRequest(request) {
826
+ return {
827
+ id: `dlg_${Date.now().toString(36)}`,
828
+ type: "dialogue",
829
+ turnIndex: request.context.currentTurn,
830
+ events: [],
831
+ commandMessage: request.playerInput,
832
+ visibleState: {},
833
+ grounding: {
834
+ currentRoomName: request.context.currentRoom,
835
+ visibleExits: [],
836
+ presentNpcNames: [],
837
+ inventoryItemNames: request.context.playerInventory,
838
+ discoveredClueNames: request.context.discoveredClues,
839
+ knownConsequences: [],
840
+ turnsRemaining: request.context.turnsRemaining
841
+ },
842
+ timestamp: Date.now(),
843
+ lang: this.lang
844
+ };
845
+ }
846
+ recordAudit(request, result, startTime) {
847
+ const record = {
848
+ requestId: `dlg_audit_${Date.now().toString(36)}`,
849
+ turnIndex: request.context.currentTurn,
850
+ requestType: "dialogue",
851
+ eventTypes: [],
852
+ commandVerb: "talk",
853
+ responseSource: "ai",
854
+ responseTextPreview: result.dialogue.slice(0, 100),
855
+ validationPassed: true,
856
+ constraintViolations: [],
857
+ providerState: this.provider.getStatus().state,
858
+ providerId: this.provider.id,
859
+ promptStructure: {
860
+ systemPromptHash: "",
861
+ groundingFactCount: 0,
862
+ eventCount: 0
863
+ },
864
+ latencyMs: Date.now() - startTime,
865
+ timestamp: Date.now()
866
+ };
867
+ this.auditLog.append(record);
868
+ }
869
+ };
870
+ // Annotate the CommonJS export names for ESM import in node:
871
+ 0 && (module.exports = {
872
+ AuditLog,
873
+ DialogueEngine,
874
+ OpenAICompatibleProvider,
875
+ PassthroughProvider,
876
+ buildNpcDialoguePrompt,
877
+ expandKeywordsBilingually,
878
+ loadConfigFromEnv,
879
+ parseAiJson,
880
+ reviewGateTrigger,
881
+ validateDialogueResponse
882
+ });
883
+ //# sourceMappingURL=index.cjs.map