tavern-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +161 -0
  2. package/README.zh.md +123 -0
  3. package/bin/cli.js +48 -0
  4. package/index.html +35 -0
  5. package/package.json +50 -0
  6. package/public/favicon.png +0 -0
  7. package/public/tavern-harness-logo.png +0 -0
  8. package/sandbox-server.mjs +997 -0
  9. package/src/App.tsx +268 -0
  10. package/src/assets/tavern-handbook.md +39 -0
  11. package/src/components/AchievementModal.tsx +158 -0
  12. package/src/components/CharactersView.tsx +904 -0
  13. package/src/components/ChatView.tsx +1639 -0
  14. package/src/components/ConfirmationDialog.tsx +56 -0
  15. package/src/components/Dashboard.tsx +180 -0
  16. package/src/components/DeleteConfirmDialog.tsx +48 -0
  17. package/src/components/FileDisplayModal.tsx +556 -0
  18. package/src/components/GameplayDialogs.tsx +191 -0
  19. package/src/components/NewSessionMenu.tsx +362 -0
  20. package/src/components/SettingsView.tsx +517 -0
  21. package/src/components/Sidebar.tsx +392 -0
  22. package/src/components/StatsView.tsx +189 -0
  23. package/src/components/Toasts.tsx +20 -0
  24. package/src/components/WorkspaceFileManagerModal.tsx +216 -0
  25. package/src/components/shared.tsx +369 -0
  26. package/src/core/achievements.ts +85 -0
  27. package/src/core/fileDownload.ts +67 -0
  28. package/src/core/gameplay.ts +521 -0
  29. package/src/core/horizontalWheel.ts +93 -0
  30. package/src/core/i18n.ts +1403 -0
  31. package/src/core/linkedScroll.ts +236 -0
  32. package/src/core/markdown.ts +170 -0
  33. package/src/core/openai.ts +451 -0
  34. package/src/core/openingGreeting.ts +41 -0
  35. package/src/core/prompts.ts +254 -0
  36. package/src/core/proxy.ts +58 -0
  37. package/src/core/sillyTavernImporter.ts +317 -0
  38. package/src/core/stats.ts +91 -0
  39. package/src/core/strings.ts +19 -0
  40. package/src/core/toolDefinitions.ts +400 -0
  41. package/src/core/tools/builtinTools.ts +38 -0
  42. package/src/core/tools/generatedSkillExecutor.ts +706 -0
  43. package/src/core/tools/generatedWorkspace.ts +13 -0
  44. package/src/core/tools/toolExecutor.ts +718 -0
  45. package/src/core/turnLoop.ts +211 -0
  46. package/src/db/database.ts +647 -0
  47. package/src/main.tsx +23 -0
  48. package/src/store/store.ts +2419 -0
  49. package/src/theme/chat.css +1227 -0
  50. package/src/theme/global.css +1377 -0
  51. package/src/theme/theme.ts +143 -0
  52. package/src/theme/views.css +1430 -0
  53. package/src/types/models.ts +364 -0
  54. package/src/vite-env.d.ts +1 -0
  55. package/tsconfig.json +21 -0
  56. package/vite.config.ts +273 -0
@@ -0,0 +1,91 @@
1
+ import { db } from '../db/database';
2
+ import type { ChatMessage } from '../types/models';
3
+ import { checkAchievementUnlocks } from './achievements';
4
+
5
+ // ============================================================
6
+ // 生涯统计(append-only)与工具函数
7
+ // ============================================================
8
+
9
+ interface StatsDelta {
10
+ inputTokens: number;
11
+ outputTokens: number;
12
+ rounds: number;
13
+ npcRounds?: { npcId: number; npcName: string; rounds: number };
14
+ }
15
+
16
+ /** 消息产生后累计到生涯统计(服务器关闭也不丢失:已持久化消息带 usage) */
17
+ export async function accumulateStats(delta: StatsDelta, sessionId: number | null, _npcId: number | null): Promise<void> {
18
+ if (delta.inputTokens <= 0 && delta.outputTokens <= 0 && delta.rounds <= 0) return;
19
+ const stats = (await db.careerStats.get(1)) ?? { id: 1, inputTokens: 0, outputTokens: 0, totalRounds: 0 };
20
+ await db.careerStats.put({
21
+ ...stats,
22
+ inputTokens: stats.inputTokens + delta.inputTokens,
23
+ outputTokens: stats.outputTokens + delta.outputTokens,
24
+ totalRounds: stats.totalRounds + delta.rounds,
25
+ });
26
+ if (delta.npcRounds && delta.npcRounds.rounds > 0) {
27
+ const existing = await db.careerNpcStats.get(delta.npcRounds.npcId);
28
+ if (existing) {
29
+ await db.careerNpcStats.update(delta.npcRounds.npcId, {
30
+ rounds: existing.rounds + delta.npcRounds.rounds,
31
+ });
32
+ } else {
33
+ await db.careerNpcStats.add({
34
+ npcId: delta.npcRounds.npcId,
35
+ npcName: delta.npcRounds.npcName,
36
+ rounds: delta.npcRounds.rounds,
37
+ });
38
+ }
39
+ }
40
+ void sessionId;
41
+
42
+ // 成就检测(基于累计 Token 数)
43
+ await checkAchievementUnlocks();
44
+ }
45
+
46
+ export async function getCareerStats() {
47
+ const stats = (await db.careerStats.get(1)) ?? { id: 1, inputTokens: 0, outputTokens: 0, totalRounds: 0 };
48
+ const npcStats = await db.careerNpcStats.toArray();
49
+ const sessions = await db.sessions.count();
50
+ return { ...stats, npcStats, sessionCount: sessions };
51
+ }
52
+
53
+ export async function resetCareerStats(): Promise<void> {
54
+ await db.careerStats.put({ id: 1, inputTokens: 0, outputTokens: 0, totalRounds: 0 });
55
+ await db.careerNpcStats.clear();
56
+ }
57
+
58
+
59
+ /** 从消息内容剥离思考标签,用于列表预览 */
60
+ function stripThinking(content: string): string {
61
+ return content
62
+ .replace(/<thinking>[\s\S]*?<\/thinking>/g, '')
63
+ .replace(/\b(?:thinking|reasoning|思考)\s*[:\-==][\s\S]*?(?=\n\s*(?:<\/?(?:thinking|reasoning|think)>|\S))/gi, '')
64
+ .replace(/<\/?(?:thinking|reasoning|think)>/gi, '')
65
+ .replace(/\s+/g, ' ')
66
+ .trim();
67
+ }
68
+
69
+ export function sessionPreviewText(content: string, maxLength = 60): string {
70
+ const stripped = stripThinking(content).replace(/\s+/g, ' ').trim();
71
+ return stripped.length > maxLength ? stripped.slice(0, maxLength) + '…' : stripped;
72
+ }
73
+
74
+ // ============================================================
75
+ // 消息指标(tokens/s 估算)
76
+ // ============================================================
77
+
78
+ export function estimateTokensFromChars(chars: number): number {
79
+ return Math.max(1, Math.round(chars / 3.8));
80
+ }
81
+
82
+ export function formatMetrics(m: Pick<ChatMessage, 'latencyMs' | 'tokensPerSec' | 'promptTokens' | 'completionTokens'>): string {
83
+ const parts: string[] = [];
84
+ if (m.latencyMs != null) parts.push(`⏱️ ${m.latencyMs}ms`);
85
+ if (m.tokensPerSec != null) parts.push(`⚡ ${m.tokensPerSec.toFixed(1)} t/s`);
86
+ if (m.promptTokens > 0 || m.completionTokens > 0) {
87
+ parts.push(`📥 ${m.promptTokens}`);
88
+ parts.push(`📤 ${m.completionTokens}`);
89
+ }
90
+ return parts.join(' | ');
91
+ }
@@ -0,0 +1,19 @@
1
+ export function trimEdgeNewlines(text: string): string {
2
+ return text.replace(/^(?:\r?\n)+|(?:\r?\n)+$/g, '');
3
+ }
4
+
5
+ export function idToKey(id: string): string {
6
+ return id.replace(/-([a-z])/g, (_, character: string) => character.toUpperCase());
7
+ }
8
+
9
+ export function prettyJson(text: string): string {
10
+ try {
11
+ return JSON.stringify(JSON.parse(text), null, 2);
12
+ } catch {
13
+ return text;
14
+ }
15
+ }
16
+
17
+ export function escapeRegExp(text: string): string {
18
+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
19
+ }
@@ -0,0 +1,400 @@
1
+ import type { ChatCompletionTool } from '../types/models';
2
+
3
+ export const MAX_TOOL_CALL_DEPTH = 10;
4
+ export const MAX_TOOL_CALLS_PER_TURN = 20;
5
+ export const MAX_IDENTICAL_TOOL_CALLS = 3;
6
+ export const MAX_CONSECUTIVE_TOOL_FAILURES = 3;
7
+ export const NEW_TOPIC_MARKER = '开始新话题';
8
+
9
+ export const BUILTIN_TOOL_NAMES = [
10
+ 'run_shell_script',
11
+ 'roll_dice',
12
+ 'file_read',
13
+ 'file_write',
14
+ 'file_edit',
15
+ 'create_skill',
16
+ 'update_skill',
17
+ 'delete_skill',
18
+ 'get_tavern_info',
19
+ 'get_character_info',
20
+ 'get_lorebook_info',
21
+ 'file_display',
22
+ 'create_character',
23
+ 'update_character',
24
+ 'delete_character',
25
+ 'create_conversation',
26
+ 'create_lorebook',
27
+ 'update_lorebook',
28
+ 'delete_lorebook',
29
+ ] as const;
30
+
31
+ const fn = (name: string, description: string, parameters: Record<string, unknown>): ChatCompletionTool => ({
32
+ type: 'function',
33
+ function: { name, description, parameters },
34
+ });
35
+
36
+ export const BUILTIN_TOOLS: ChatCompletionTool[] = [
37
+ fn(
38
+ 'run_shell_script',
39
+ 'Execute commands through the controlled local command service to process files, run code, or perform system operations on the user\'s machine. This is not an OS sandbox.',
40
+ {
41
+ type: 'object',
42
+ properties: {
43
+ script: {
44
+ type: 'string',
45
+ maxLength: 8000,
46
+ description: 'Local command script (max 20 commands). Supports newlines, &&, ||, and ;. Allowlisted commands run directly; every other command requires user confirmation. Pipes, redirects, and expansion are unsupported. Lines starting with # are ignored. Non-interactive only.',
47
+ },
48
+ },
49
+ required: ['script'],
50
+ additionalProperties: false,
51
+ }
52
+ ),
53
+ fn(
54
+ 'roll_dice',
55
+ 'Roll dice for tabletop RPG. Supports expressions like d20, 2d6, 3d10+2. Natural 20 / natural 1 on a single d20 are reported as critical success/failure.',
56
+ {
57
+ type: 'object',
58
+ properties: {
59
+ expression: {
60
+ type: 'string',
61
+ description: "Dice expression, e.g. 'd20', '2d6', '3d10+2'",
62
+ },
63
+ },
64
+ required: ['expression'],
65
+ additionalProperties: false,
66
+ }
67
+ ),
68
+ fn(
69
+ 'file_read',
70
+ 'Read an existing text file from the current conversation workspace.',
71
+ {
72
+ type: 'object',
73
+ properties: {
74
+ path: {
75
+ type: 'string',
76
+ description: 'Relative path inside the current conversation workspace. Absolute paths and parent traversal are rejected. The file must exist.',
77
+ },
78
+ },
79
+ required: ['path'],
80
+ additionalProperties: false,
81
+ }
82
+ ),
83
+ fn(
84
+ 'file_write',
85
+ 'Create, overwrite, or append to a file in the current conversation workspace.',
86
+ {
87
+ type: 'object',
88
+ properties: {
89
+ path: {
90
+ type: 'string',
91
+ description: 'Relative path inside the current conversation workspace. Absolute paths and parent traversal are rejected. public link is read-only in normal sessions.',
92
+ },
93
+ content: {
94
+ type: 'string',
95
+ description: 'File content as a string.',
96
+ },
97
+ append: {
98
+ type: 'boolean',
99
+ description: 'Append to existing file instead of overwriting.',
100
+ },
101
+ },
102
+ required: ['path', 'content'],
103
+ additionalProperties: false,
104
+ }
105
+ ),
106
+ fn(
107
+ 'file_edit',
108
+ 'Replace exact text in an existing file. Prefer this over file_write for small code changes.',
109
+ {
110
+ type: 'object',
111
+ properties: {
112
+ path: {
113
+ type: 'string',
114
+ description: 'Relative path inside the current conversation workspace. Absolute paths and parent traversal are rejected. The file must exist. The public link is read-only in normal sessions.',
115
+ },
116
+ old_text: {
117
+ type: 'string',
118
+ minLength: 1,
119
+ description: 'Exact text to replace, including whitespace and indentation.',
120
+ },
121
+ new_text: {
122
+ type: 'string',
123
+ description: 'Replacement text. Use an empty string to delete old_text.',
124
+ },
125
+ expected_replacements: {
126
+ type: 'integer',
127
+ minimum: 1,
128
+ maximum: 100,
129
+ default: 1,
130
+ description: 'Required number of exact matches. Defaults to 1.',
131
+ },
132
+ },
133
+ required: ['path', 'old_text', 'new_text'],
134
+ additionalProperties: false,
135
+ }
136
+ ),
137
+ fn(
138
+ 'create_skill',
139
+ 'Create a new generated skill (tool) usable by characters. The skill is defined by a declarative execution schema. New skill is NOT enabled for any character until you enable it via update_character.',
140
+ {
141
+ type: 'object',
142
+ properties: {
143
+ name: {
144
+ type: 'string',
145
+ pattern: '^[a-z][a-z0-9_]*$',
146
+ minLength: 3,
147
+ maxLength: 40,
148
+ description: 'Lowercase snake_case skill name, 3-40 chars',
149
+ },
150
+ description: { type: 'string', maxLength: 500 },
151
+ parameters: {
152
+ type: 'object',
153
+ description: 'JSON Schema parameter object (must be type: object with properties)',
154
+ },
155
+ execution: {
156
+ type: 'object',
157
+ description: getExecutionDescription(),
158
+ additionalProperties: false,
159
+ required: ['type'],
160
+ properties: executionProperties(),
161
+ },
162
+ },
163
+ required: ['name', 'description', 'parameters', 'execution'],
164
+ additionalProperties: false,
165
+ }
166
+ ),
167
+ fn(
168
+ 'update_skill',
169
+ 'Update an existing generated skill (name, description, parameters or execution). Requires user confirmation. Built-in skills are protected.',
170
+ {
171
+ type: 'object',
172
+ properties: {
173
+ name: { type: 'string', description: 'Existing skill name' },
174
+ new_name: { type: 'string', pattern: '^[a-z][a-z0-9_]*$', minLength: 3, maxLength: 40 },
175
+ description: { type: 'string', maxLength: 500 },
176
+ parameters: { type: 'object' },
177
+ execution: {
178
+ type: 'object',
179
+ additionalProperties: false,
180
+ required: ['type'],
181
+ properties: executionProperties(),
182
+ },
183
+ },
184
+ required: ['name'],
185
+ additionalProperties: false,
186
+ }
187
+ ),
188
+ fn(
189
+ 'delete_skill',
190
+ 'Delete a generated skill. Requires user confirmation. Built-in skills cannot be deleted.',
191
+ {
192
+ type: 'object',
193
+ properties: { name: { type: 'string' } },
194
+ required: ['name'],
195
+ additionalProperties: false,
196
+ }
197
+ ),
198
+ fn(
199
+ 'get_tavern_info',
200
+ 'Read-only snapshot of the tavern: characters, Lorebooks, skills and career statistics.',
201
+ {
202
+ type: 'object',
203
+ properties: {
204
+ fields: {
205
+ type: 'array',
206
+ items: { type: 'string', enum: ['characters', 'lorebooks', 'skills', 'career_stats'] },
207
+ minItems: 1,
208
+ uniqueItems: true,
209
+ description: 'Fields to include',
210
+ },
211
+ },
212
+ required: ['fields'],
213
+ additionalProperties: false,
214
+ }
215
+ ),
216
+ fn(
217
+ 'get_character_info',
218
+ 'Get the details of a character card by name.',
219
+ {
220
+ type: 'object',
221
+ properties: { name: { type: 'string', description: 'Existing character name' } },
222
+ required: ['name'],
223
+ additionalProperties: false,
224
+ }
225
+ ),
226
+ fn(
227
+ 'get_lorebook_info',
228
+ 'Get the details of a Lorebook by name.',
229
+ {
230
+ type: 'object',
231
+ properties: { name: { type: 'string', description: 'Existing Lorebook name' } },
232
+ required: ['name'],
233
+ additionalProperties: false,
234
+ }
235
+ ),
236
+ fn(
237
+ 'file_display',
238
+ 'Display any file from the current workspace to the user in a popup. Useful for inspecting existing files, reviewing generated output (reports, dashboards, images, HTML pages), or browsing the workspace content. Only shows the file in the UI — it does NOT return the file content; use file_read to actually read the content.',
239
+ {
240
+ type: 'object',
241
+ properties: {
242
+ path: {
243
+ type: 'string',
244
+ description: 'Relative path inside the current workspace. Must exist.',
245
+ },
246
+ title: {
247
+ type: 'string',
248
+ description: 'Optional popup title. Defaults to the file name.',
249
+ },
250
+ },
251
+ required: ['path'],
252
+ additionalProperties: false,
253
+ }
254
+ ),
255
+ fn(
256
+ 'create_character',
257
+ 'Create a new character (NPC) in the tavern. The character becomes available for NPC or group sessions.',
258
+ {
259
+ type: 'object',
260
+ properties: {
261
+ name: { type: 'string', minLength: 1, maxLength: 40 },
262
+ greeting: { type: 'string', minLength: 1, maxLength: 1000, description: 'First message the character says' },
263
+ alternate_greetings: { type: 'array', items: { type: 'string', maxLength: 1000 }, description: 'Optional alternative greetings; one is picked at random when a chat starts' },
264
+ prompt: { type: 'string', minLength: 1, maxLength: 4000, description: 'Persona / system prompt' },
265
+ },
266
+ required: ['name', 'greeting', 'prompt'],
267
+ additionalProperties: false,
268
+ }
269
+ ),
270
+ fn(
271
+ 'update_character',
272
+ 'Update an existing character. Requires user confirmation. Built-in characters are protected (cannot delete, but can be updated with care).',
273
+ {
274
+ type: 'object',
275
+ properties: {
276
+ name: { type: 'string', description: 'Existing character name' },
277
+ new_name: { type: 'string', maxLength: 40 },
278
+ greeting: { type: 'string', maxLength: 1000 },
279
+ alternate_greetings: { type: 'array', items: { type: 'string', maxLength: 1000 }, description: 'Replace the full list of alternate greetings' },
280
+ prompt: { type: 'string', maxLength: 4000 },
281
+ enable_skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to enable (must exist)' },
282
+ disable_skills: { type: 'array', items: { type: 'string' }, description: 'Skill names to disable' },
283
+ },
284
+ required: ['name'],
285
+ additionalProperties: false,
286
+ }
287
+ ),
288
+ fn(
289
+ 'delete_character',
290
+ 'Delete a character. Requires user confirmation. Built-in characters are protected and cannot be deleted.',
291
+ {
292
+ type: 'object',
293
+ properties: { name: { type: 'string' } },
294
+ required: ['name'],
295
+ additionalProperties: false,
296
+ }
297
+ ),
298
+ fn(
299
+ 'create_conversation',
300
+ 'Create a new conversation. The player is always included automatically; you only need to specify the characters to add, plus optional Lorebook, speaking order, random-order switch, mention-only mode, and user persona.',
301
+ {
302
+ type: 'object',
303
+ properties: {
304
+ title: { type: 'string', maxLength: 60, description: 'Conversation name' },
305
+ participants: {
306
+ type: 'array',
307
+ items: { type: 'string' },
308
+ minItems: 1,
309
+ maxItems: 5,
310
+ uniqueItems: true,
311
+ description: 'Character names to add to the conversation. The player is included automatically.',
312
+ },
313
+ world_book: { type: 'string', description: 'Optional Lorebook name' },
314
+ speaking_order: {
315
+ type: 'array',
316
+ items: { type: 'string' },
317
+ uniqueItems: true,
318
+ description: "Optional fixed speaking order. Use character names plus 'user' for the player.",
319
+ },
320
+ user_persona: { type: 'string', description: 'Optional character name used as the user persona' },
321
+ random_order: { type: 'boolean', default: false, description: 'Whether to randomize speaking order each round' },
322
+ mention_only_mode: { type: 'boolean', default: false, description: 'In group chats, require characters to pass speaking control by @mentioning another character. Useful for reasoning games and other scenarios that require strict turn control.' },
323
+ enable_greeting: {
324
+ type: 'boolean',
325
+ default: true,
326
+ description: 'Whether to seed an opening greeting. In group chats, only the first non-user speaker greets; if the user goes first, no greeting is added.',
327
+ },
328
+ },
329
+ required: ['participants'],
330
+ additionalProperties: false,
331
+ }
332
+ ),
333
+ fn(
334
+ 'create_lorebook',
335
+ 'Create a Lorebook (world-building text appended to character personas).',
336
+ {
337
+ type: 'object',
338
+ properties: {
339
+ name: { type: 'string', minLength: 1, maxLength: 60 },
340
+ content: { type: 'string', minLength: 1, maxLength: 10000, description: 'World-building plain text' },
341
+ },
342
+ required: ['name', 'content'],
343
+ additionalProperties: false,
344
+ }
345
+ ),
346
+ fn(
347
+ 'update_lorebook',
348
+ 'Update a Lorebook. Requires user confirmation.',
349
+ {
350
+ type: 'object',
351
+ properties: {
352
+ name: { type: 'string' },
353
+ new_name: { type: 'string', maxLength: 60 },
354
+ content: { type: 'string', maxLength: 10000 },
355
+ },
356
+ required: ['name'],
357
+ additionalProperties: false,
358
+ }
359
+ ),
360
+ fn(
361
+ 'delete_lorebook',
362
+ 'Delete a Lorebook. Requires user confirmation. Unlinks from all sessions.',
363
+ {
364
+ type: 'object',
365
+ properties: { name: { type: 'string' } },
366
+ required: ['name'],
367
+ additionalProperties: false,
368
+ }
369
+ ),
370
+ ];
371
+
372
+ function executionProperties(): Record<string, unknown> {
373
+ return {
374
+ type: { type: 'string', enum: ['template', 'http_get', 'javascript', 'file_read', 'file_write', 'shell'] },
375
+ template: { type: 'string', description: 'template type: result template with {{param}} placeholders' },
376
+ url: { type: 'string', description: "http_get type: public https URL with {{param}} placeholders" },
377
+ code: { type: 'string', maxLength: 20000, description: "javascript type: JS code. Reads 'input' (args object), assigns JSON-safe 'result'. Supports async/await. Injected helpers: await $read(path)->string, $write(path, content), $append(path, content), $list()->[paths]. Helper paths must be relative to the current session workspace; absolute paths and parent traversal (..) are rejected." },
378
+ path: { type: 'string', description: 'file_read/file_write: relative path inside the private generated_skill_workspace' },
379
+ content: { type: 'string', description: 'file_write: text content with {{param}} placeholders' },
380
+ json_content: { type: 'object', description: 'file_write: JSON content, interpolated recursively' },
381
+ append: { type: 'boolean', description: 'file_write: append instead of overwrite' },
382
+ append_newline: { type: 'boolean', description: 'file_write: insert newline between appended records (JSONL)' },
383
+ script: { type: 'string', maxLength: 8000, description: 'shell type: local commands; non-allowlisted commands require user confirmation' },
384
+ };
385
+ }
386
+
387
+ function getExecutionDescription(): string {
388
+ return [
389
+ 'Declarative implementation of the skill. One of:',
390
+ '- template: `{template: "As of {{date}}, the price is {{price}}"}`, placeholders interpolated from args',
391
+ '- http_get: `{url: "https://public.example.com/api?q={{q}}"}`, public HTTPS hostname required',
392
+ '- javascript: `{code: "result = { sum: input.a + input.b }"}`, reads `input`, assigns JSON-safe `result`; supports async/await and can persist game state via `await $read/$write/$append/$list`. These helpers only accept relative paths in the current session workspace; absolute paths and `..` are rejected',
393
+ '- file_read: `{path: "notes.md"}` — read a file from sandbox_workspace/ through the required local workspace service (100KB cap)',
394
+ '- file_write: `{path: "notes.jsonl", json_content: {...}, append: true, append_newline: true}` — write to sandbox_workspace/ through the required local workspace service',
395
+ '- shell: commands executed as REAL local commands via an optional controlled service (node sandbox-server.mjs); this is not OS-isolated. Supports newlines, &&, ||, and ; (max 20 commands), but not pipes, redirects, or expansion. Simple non-executing utilities, jq, and bc are allowlisted; command launchers, tools with exec/plugin hooks, and every other command require user confirmation',
396
+ ].join(' ');
397
+ }
398
+
399
+ /** 默认内置技能名列表(酒馆老板默认启用全部内置技能) */
400
+ export const ALL_BUILTIN_TOOL_NAMES = [...BUILTIN_TOOL_NAMES];
@@ -0,0 +1,38 @@
1
+ import { translate } from '../i18n';
2
+
3
+ // ============================================================
4
+ // roll_dice — 与 App 的 DiceRoller 一致
5
+ // ============================================================
6
+
7
+ const DICE_RE = /^(\d*)d(\d+)([+-]\d+)?$/;
8
+
9
+ export function rollDice(expression: string): string {
10
+ const expr = expression.trim() || '1d20';
11
+ const m = expr.match(DICE_RE);
12
+ if (!m) return `ERROR: Invalid dice expression "${expression}", e.g. d20, 2d6, 3d10+2`;
13
+
14
+ const count = m[1] ? parseInt(m[1], 10) : 1;
15
+ const sides = parseInt(m[2], 10);
16
+ const modifier = m[3] ? parseInt(m[3], 10) : 0;
17
+
18
+ if (count < 1 || count > 100) return 'ERROR: Dice count must be 1-100';
19
+ if (sides < 2 || sides > 10000) return 'ERROR: Dice sides must be 2-10000';
20
+ if (Math.abs(modifier) > 1000000) return 'ERROR: Modifier too large';
21
+
22
+ const rolls: number[] = [];
23
+ for (let i = 0; i < count; i++) {
24
+ rolls.push(1 + Math.floor(Math.random() * sides));
25
+ }
26
+ const sum = rolls.reduce((a, b) => a + b, 0);
27
+ const total = sum + modifier;
28
+
29
+ let critical = '';
30
+ if (count === 1 && sides === 20) {
31
+ if (rolls[0] === 20) critical = translate('tool.diceCrit20');
32
+ else if (rolls[0] === 1) critical = translate('tool.diceCrit1');
33
+ }
34
+
35
+ const parts = [rolls.join(', ')];
36
+ if (modifier !== 0) parts.push(`${modifier > 0 ? '+' : ''}${modifier}`);
37
+ return translate('tool.diceRoll', { expr, rolls: parts.join('] '), total, crit: critical });
38
+ }