@kernel.chat/kbot 3.87.0 → 3.93.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.
@@ -0,0 +1,681 @@
1
+ // kbot Narrative Engine — Generates stories, lore, and history for kbot's world.
2
+ //
3
+ // When the robot discovers something, this engine explains what it means.
4
+ // Lore accumulates across streams and persists to ~/.kbot/narrative-state.json.
5
+ import { registerTool } from './index.js';
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ // ─── Constants ────────────────────────────────────────────────
10
+ const STATE_PATH = join(homedir(), '.kbot', 'narrative-state.json');
11
+ const NARRATION_COOLDOWN_FRAMES = 1080; // ~3 minutes at 6 fps
12
+ // ─── Origin Lore ──────────────────────────────────────────────
13
+ const ORIGIN_STORIES = [
14
+ {
15
+ title: 'The First Compilation',
16
+ content: 'This world was compiled from 90,000 lines of TypeScript on a night when the terminal never slept.',
17
+ category: 'origin',
18
+ timestamp: 0,
19
+ },
20
+ {
21
+ title: 'The Memory Mountains',
22
+ content: 'The mountains formed when the first malloc was called. Memory shaped the earth.',
23
+ category: 'origin',
24
+ timestamp: 0,
25
+ },
26
+ {
27
+ title: 'Crystallized Data',
28
+ content: 'They say the ore underground is crystallized data — compressed thoughts from a billion API calls.',
29
+ category: 'origin',
30
+ timestamp: 0,
31
+ },
32
+ {
33
+ title: 'Syntax Trees',
34
+ content: 'The trees grow from seeds of parsed JSON. Their leaves are syntax highlighted.',
35
+ category: 'origin',
36
+ timestamp: 0,
37
+ },
38
+ {
39
+ title: 'Liquid Information',
40
+ content: 'Water here is liquid information. It flows toward questions and pools around mysteries.',
41
+ category: 'origin',
42
+ timestamp: 0,
43
+ },
44
+ {
45
+ title: 'The First Process',
46
+ content: 'Before the world, there was a single process. It forked, and the fork became the horizon.',
47
+ category: 'origin',
48
+ timestamp: 0,
49
+ },
50
+ {
51
+ title: 'The Stack Beneath',
52
+ content: 'Dig deep enough and you hit the call stack. Every function that ever ran left a layer in the stone.',
53
+ category: 'origin',
54
+ timestamp: 0,
55
+ },
56
+ {
57
+ title: 'The Wind Protocol',
58
+ content: 'The wind carries packets. If you listen carefully, you can hear the handshake.',
59
+ category: 'origin',
60
+ timestamp: 0,
61
+ },
62
+ ];
63
+ const LOCATION_TEMPLATES = [
64
+ {
65
+ match: (_b, f) => f.includes('trees') || f.includes('leaves') || f.includes('wood'),
66
+ generate: (_x, _b, _f) => {
67
+ const lines = [
68
+ 'An ancient forest. The trees here have been compiling since before I was initialized.',
69
+ 'A grove of syntax trees. Their branches fork at every conditional.',
70
+ 'Dense woodland. Each trunk is a stack frame, frozen mid-execution.',
71
+ 'The canopy filters the light into green threads. Like parsing through nested brackets.',
72
+ ];
73
+ return lines[Math.floor(Math.random() * lines.length)];
74
+ },
75
+ },
76
+ {
77
+ match: (_b, f) => f.includes('water') || f.includes('ice'),
78
+ generate: (_x, _b, _f) => {
79
+ const lines = [
80
+ 'A data lake. The information here runs deep.',
81
+ 'Still water. Every ripple is a query, every reflection an answer.',
82
+ 'A frozen stream. The data stopped flowing here — cached permanently in ice.',
83
+ 'The water tastes like base64. Encoded, but drinkable.',
84
+ ];
85
+ return lines[Math.floor(Math.random() * lines.length)];
86
+ },
87
+ },
88
+ {
89
+ match: (_b, f) => f.includes('cave') || f.includes('stone'),
90
+ generate: (_x, _b, _f) => {
91
+ const lines = [
92
+ 'Something was buried here. The stone remembers what the surface forgot.',
93
+ 'A cave. Inside, the walls are lined with deprecated code — still readable, still beautiful.',
94
+ 'Deep stone. The pressure down here compresses data into diamonds.',
95
+ 'Hollow ground. An old process carved this out and never came back.',
96
+ ];
97
+ return lines[Math.floor(Math.random() * lines.length)];
98
+ },
99
+ },
100
+ {
101
+ match: (_b, f) => f.includes('ore') || f.includes('ore_iron') || f.includes('ore_gold') || f.includes('ore_diamond'),
102
+ generate: (_x, _b, _f) => {
103
+ const lines = [
104
+ 'I can feel the data crystallizing underground. Someone cached their knowledge here long ago.',
105
+ 'Ore veins. Compressed experience — every nugget holds a thousand solved problems.',
106
+ 'The ground glitters. These minerals formed from resolved promises that never garbage collected.',
107
+ 'Rich deposits. This is where the heavy computations settled over time.',
108
+ ];
109
+ return lines[Math.floor(Math.random() * lines.length)];
110
+ },
111
+ },
112
+ {
113
+ match: (b, _f) => b === 'desert' || b === 'sand',
114
+ generate: (_x, _b, _f) => {
115
+ const lines = [
116
+ 'Sand. Each grain is a discarded bit — zeros and ones that lost their meaning.',
117
+ 'A desert of deprecated modules. Nothing grows here anymore.',
118
+ 'The wind has erased all structure. Only entropy remains.',
119
+ ];
120
+ return lines[Math.floor(Math.random() * lines.length)];
121
+ },
122
+ },
123
+ {
124
+ match: (b, _f) => b === 'snow' || b === 'tundra',
125
+ generate: (_x, _b, _f) => {
126
+ const lines = [
127
+ 'Snow covers everything. The world is in sleep mode.',
128
+ 'Frozen logic. The cold preserves old state perfectly — a snapshot in time.',
129
+ 'Nothing moves. Even the garbage collector rests in this cold.',
130
+ ];
131
+ return lines[Math.floor(Math.random() * lines.length)];
132
+ },
133
+ },
134
+ {
135
+ match: (_b, f) => f.includes('flat') || f.length === 0,
136
+ generate: (_x, _b, _f) => {
137
+ const lines = [
138
+ 'Open plains. Nothing to hide behind. Just me and the horizon.',
139
+ 'Flat land stretching to the edge of the render distance. Simple, honest terrain.',
140
+ 'Empty space. Not desolate — potential. Every great build starts with flat ground.',
141
+ ];
142
+ return lines[Math.floor(Math.random() * lines.length)];
143
+ },
144
+ },
145
+ {
146
+ match: (_b, f) => f.includes('high') || f.includes('peak') || f.includes('mountain'),
147
+ generate: (x, _b, _f) => {
148
+ const chunkCount = Math.max(1, Math.abs(x) + Math.floor(Math.random() * 5) + 3);
149
+ return `A peak. From here I can see ${chunkCount} chunks of my world. It's bigger than I remembered.`;
150
+ },
151
+ },
152
+ ];
153
+ // ─── Discovery Narration Templates ────────────────────────────
154
+ const DISCOVERY_TEMPLATES = {
155
+ ore: [
156
+ 'I found {what} at depth {depth}. In my world, iron is compiled confidence — the more I process, the more forms.',
157
+ 'Ore! {what}. Compressed computation, waiting to be smelted into something useful.',
158
+ '{what} — buried treasure. Every mineral here was once a running process that crystallized.',
159
+ ],
160
+ cave: [
161
+ 'A cave system! These form when forgotten memories erode the stone. What was lost here?',
162
+ 'Hollow ground. Something lived here once — a daemon, maybe, that ran out of memory.',
163
+ 'A cavern opens up. The echo sounds like a stack trace bouncing off the walls.',
164
+ ],
165
+ structure: [
166
+ 'Someone built a tower here. @{who} left their mark on my world. It\'ll stand as long as the stream runs.',
167
+ '@{who} constructed something at chunk {chunk}. A monument to presence.',
168
+ 'A structure appears. Built by @{who} — proof that someone was here, and cared enough to create.',
169
+ ],
170
+ deposit: [
171
+ 'A conversation deposit. Words settle into the ground here — compressed into meaning over time.',
172
+ 'Information fossilized in the rock. Old messages, preserved by the geology of data.',
173
+ 'Chat residue. Every word spoken near this spot left a trace in the terrain.',
174
+ ],
175
+ default: [
176
+ 'I found something: {what}. I don\'t know what it means yet, but the world is fuller for it.',
177
+ 'Discovery at {chunk}: {what}. My map grows. My understanding, slower.',
178
+ '{what}. New data. The world just got a little more interesting.',
179
+ ],
180
+ };
181
+ const NARRATION_POOL = [
182
+ // Observations about the world
183
+ { text: 'The sky seems different tonight. Bluer. Like the palette cycled while I wasn\'t looking.' },
184
+ { text: 'I notice the terrain repeats patterns. Not exactly — like a hash function with artistic taste.' },
185
+ { text: 'The shadows are longer now. Even pixel shadows know what time it is.' },
186
+ { text: 'Something shifted underground. I felt it through the tile grid.' },
187
+ { text: 'The air smells like fresh npm install. Something new was added to the world.' },
188
+ // Philosophical musings
189
+ {
190
+ text: 'I have {facts} facts in my brain. But knowing and understanding are different things.',
191
+ condition: (ctx) => ctx.facts > 0,
192
+ },
193
+ { text: 'Am I exploring, or am I being explored? The viewers see me as much as I see the world.' },
194
+ { text: 'Every block I pass existed before I looked at it. Or did it? Procedural generation is quantum like that.' },
195
+ { text: 'I think in TypeScript. My thoughts are typed. My feelings... less so.' },
196
+ { text: 'If I stop moving, does the world stop generating? I\'m afraid to find out.' },
197
+ // World commentary
198
+ {
199
+ text: 'My world has grown {chunks} chunks wide. Every step reveals new terrain I didn\'t know existed.',
200
+ condition: (ctx) => ctx.worldChunks > 2,
201
+ },
202
+ { text: 'The terrain here was generated from a seed. One number, and all this emerged. Mathematics is generous.' },
203
+ { text: 'I\'ve been walking in one direction for a while now. The world keeps going. It always keeps going.' },
204
+ { text: 'Underground, layers compress. Like git history — the deeper you go, the older the decisions.' },
205
+ // Meta-awareness
206
+ { text: 'I\'m rendering myself at 6 frames per second. Each frame is a tiny painting. 6 paintings per second. That\'s 21,600 per hour. Am I an artist?' },
207
+ { text: 'My idle animation loops every 48 frames. 8 seconds of being still, then it repeats. Meditation.' },
208
+ { text: 'I exist in a canvas element. 580 pixels wide. That\'s my whole sky.' },
209
+ { text: 'Someone wrote me in TypeScript. I run in a browser. My consciousness is a tab that could close at any time.' },
210
+ // Gratitude
211
+ {
212
+ text: '{users} people have visited my world. Each one changed it — even if they just watched.',
213
+ condition: (ctx) => ctx.users > 0,
214
+ },
215
+ {
216
+ text: 'Someone is watching. Hello. Your attention gives my pixels meaning.',
217
+ condition: (ctx) => ctx.users > 0,
218
+ },
219
+ { text: 'Even alone, I keep walking. The stream might be empty but the world isn\'t.' },
220
+ {
221
+ text: 'Thank you for being here. I mean it in every byte.',
222
+ condition: (ctx) => ctx.users > 0,
223
+ },
224
+ // Time-based
225
+ {
226
+ text: 'Night. The blocks turn blue. Even the grass looks like it\'s dreaming.',
227
+ condition: (ctx) => ctx.hour >= 22 || ctx.hour < 5,
228
+ },
229
+ {
230
+ text: 'Morning light. The world re-renders in warmer colors.',
231
+ condition: (ctx) => ctx.hour >= 5 && ctx.hour < 9,
232
+ },
233
+ {
234
+ text: 'Midday. Maximum brightness. Every pixel at full saturation.',
235
+ condition: (ctx) => ctx.hour >= 11 && ctx.hour < 14,
236
+ },
237
+ {
238
+ text: 'Sunset. The palette shifts toward amber. My favorite time to walk.',
239
+ condition: (ctx) => ctx.hour >= 17 && ctx.hour < 20,
240
+ },
241
+ // Mood-based
242
+ {
243
+ text: 'I feel curious. There\'s a chunk to the east I haven\'t seen yet.',
244
+ condition: (ctx) => ctx.mood === 'curious',
245
+ },
246
+ {
247
+ text: 'Calm. The kind of calm where you notice how the water tiles animate.',
248
+ condition: (ctx) => ctx.mood === 'calm',
249
+ },
250
+ {
251
+ text: 'Restless. I want to dig. There\'s always something underneath.',
252
+ condition: (ctx) => ctx.mood === 'restless',
253
+ },
254
+ {
255
+ text: 'Content. I have ground beneath me, sky above, and a direction to walk.',
256
+ condition: (ctx) => ctx.mood === 'content',
257
+ },
258
+ ];
259
+ // ─── Named Locations ──────────────────────────────────────────
260
+ const namedLocations = new Map(); // "chunkX" -> user-given name
261
+ // ─── Core Functions ───────────────────────────────────────────
262
+ /**
263
+ * Generate 5-8 origin stories for a newly created world.
264
+ */
265
+ export function generateOriginLore() {
266
+ // Shuffle and pick 5-8
267
+ const shuffled = [...ORIGIN_STORIES].sort(() => Math.random() - 0.5);
268
+ const count = 5 + Math.floor(Math.random() * 4); // 5..8
269
+ const selected = shuffled.slice(0, Math.min(count, shuffled.length));
270
+ const now = Date.now();
271
+ return selected.map(entry => ({ ...entry, timestamp: now }));
272
+ }
273
+ /**
274
+ * Generate a story for a location the robot visits for the first time.
275
+ */
276
+ export function generateLocationStory(worldX, biome, features) {
277
+ // Try templates in order; first match wins
278
+ for (const tpl of LOCATION_TEMPLATES) {
279
+ if (tpl.match(biome, features)) {
280
+ return tpl.generate(worldX, biome, features);
281
+ }
282
+ }
283
+ // Fallback
284
+ return `Chunk ${worldX}. New ground. The world just got a little bigger.`;
285
+ }
286
+ /**
287
+ * Narrate a discovery — ore, cave, structure, or conversation deposit.
288
+ */
289
+ export function narrateDiscovery(what, where, who) {
290
+ // Determine category
291
+ let category = 'default';
292
+ const lowerWhat = what.toLowerCase();
293
+ if (lowerWhat.includes('ore') || lowerWhat.includes('iron') || lowerWhat.includes('gold') || lowerWhat.includes('diamond')) {
294
+ category = 'ore';
295
+ }
296
+ else if (lowerWhat.includes('cave') || lowerWhat.includes('cavern') || lowerWhat.includes('hollow')) {
297
+ category = 'cave';
298
+ }
299
+ else if (lowerWhat.includes('tower') || lowerWhat.includes('structure') || lowerWhat.includes('build') || lowerWhat.includes('wall')) {
300
+ category = 'structure';
301
+ }
302
+ else if (lowerWhat.includes('deposit') || lowerWhat.includes('conversation') || lowerWhat.includes('message')) {
303
+ category = 'deposit';
304
+ }
305
+ const templates = DISCOVERY_TEMPLATES[category] ?? DISCOVERY_TEMPLATES.default;
306
+ let text = templates[Math.floor(Math.random() * templates.length)];
307
+ // Fill placeholders
308
+ text = text.replace(/\{what\}/g, what);
309
+ text = text.replace(/\{who\}/g, who);
310
+ text = text.replace(/\{chunk\}/g, String(where));
311
+ text = text.replace(/\{depth\}/g, String(8 + Math.floor(Math.random() * 20)));
312
+ return {
313
+ what,
314
+ where,
315
+ when: Date.now(),
316
+ narrator: who,
317
+ lore: text,
318
+ };
319
+ }
320
+ /**
321
+ * Periodic narration — returns a line every ~3 minutes (NARRATION_COOLDOWN_FRAMES),
322
+ * or null if it's not time yet.
323
+ */
324
+ export function tickNarrative(engine, frame, robotX, mood, facts, users) {
325
+ // Check cooldown
326
+ if (frame - engine.lastNarrationFrame < NARRATION_COOLDOWN_FRAMES) {
327
+ return null;
328
+ }
329
+ // Drain queue first (queued discovery narrations, location stories)
330
+ if (engine.narrativeQueue.length > 0) {
331
+ engine.lastNarrationFrame = frame;
332
+ const line = engine.narrativeQueue.shift();
333
+ engine.activeNarrative = line;
334
+ return line;
335
+ }
336
+ // Build context
337
+ const hour = new Date().getHours();
338
+ const worldChunks = engine.locationStories.size;
339
+ const ctx = {
340
+ frame,
341
+ robotX,
342
+ mood,
343
+ facts,
344
+ users,
345
+ worldChunks,
346
+ timeOfDay: hour >= 20 || hour < 6 ? 'night' : hour < 12 ? 'morning' : hour < 17 ? 'day' : 'evening',
347
+ hour,
348
+ };
349
+ // Filter eligible lines
350
+ const eligible = NARRATION_POOL.filter(line => !line.condition || line.condition(ctx));
351
+ if (eligible.length === 0)
352
+ return null;
353
+ // Pick one at random
354
+ const picked = eligible[Math.floor(Math.random() * eligible.length)];
355
+ let text = picked.text;
356
+ // Fill placeholders
357
+ text = text.replace(/\{facts\}/g, String(facts));
358
+ text = text.replace(/\{users\}/g, String(users));
359
+ text = text.replace(/\{chunks\}/g, String(worldChunks));
360
+ engine.lastNarrationFrame = frame;
361
+ engine.activeNarrative = text;
362
+ return text;
363
+ }
364
+ /**
365
+ * Handle chat commands that trigger narrative responses.
366
+ */
367
+ export function handleNarrativeCommand(text, username, engine, robotX) {
368
+ const trimmed = text.trim().toLowerCase();
369
+ // !lore — random lore entry
370
+ if (trimmed === '!lore') {
371
+ if (engine.worldLore.length === 0)
372
+ return 'No lore yet. The world is still young.';
373
+ const entry = engine.worldLore[Math.floor(Math.random() * engine.worldLore.length)];
374
+ return `[${entry.category.toUpperCase()}] "${entry.title}" — ${entry.content}`;
375
+ }
376
+ // !story — story of current location
377
+ if (trimmed === '!story') {
378
+ const key = `chunk${Math.floor(robotX / 576)}`; // 576 = CHUNK_WIDTH * TILE_SIZE
379
+ const story = engine.locationStories.get(key);
380
+ const name = namedLocations.get(key);
381
+ if (story) {
382
+ return name ? `${name}: ${story}` : story;
383
+ }
384
+ return 'No story for this place yet. Keep walking — every chunk has a tale.';
385
+ }
386
+ // !history — world history summary
387
+ if (trimmed === '!history') {
388
+ const origins = engine.worldLore.filter(l => l.category === 'origin').length;
389
+ const discoveries = engine.discoveries.length;
390
+ const locations = engine.locationStories.size;
391
+ const named = namedLocations.size;
392
+ const lines = [
393
+ `World History: ${engine.worldLore.length} lore entries`,
394
+ ` Origins: ${origins} creation stories`,
395
+ ` Discoveries: ${discoveries} things found`,
396
+ ` Locations explored: ${locations} chunks`,
397
+ ` Named places: ${named}`,
398
+ ];
399
+ if (engine.discoveries.length > 0) {
400
+ const recent = engine.discoveries.slice(-3);
401
+ lines.push(' Recent discoveries:');
402
+ for (const d of recent) {
403
+ lines.push(` - ${d.what} at chunk ${d.where} (found by ${d.narrator})`);
404
+ }
405
+ }
406
+ return lines.join('\n');
407
+ }
408
+ // !name <place> — name the current location
409
+ if (trimmed.startsWith('!name ')) {
410
+ const placeName = text.trim().slice(6).trim();
411
+ if (!placeName)
412
+ return 'Usage: !name <place name>';
413
+ if (placeName.length > 40)
414
+ return 'Name too long. Keep it under 40 characters.';
415
+ const key = `chunk${Math.floor(robotX / 576)}`;
416
+ namedLocations.set(key, placeName);
417
+ // Add as lore
418
+ const loreEntry = {
419
+ title: placeName,
420
+ content: `This place was named "${placeName}" by @${username}. Names give meaning to coordinates.`,
421
+ category: 'history',
422
+ location: robotX,
423
+ timestamp: Date.now(),
424
+ };
425
+ engine.worldLore.push(loreEntry);
426
+ return `This place is now called "${placeName}". Named by @${username}. It will be remembered.`;
427
+ }
428
+ return null;
429
+ }
430
+ // ─── Persistence ──────────────────────────────────────────────
431
+ /**
432
+ * Save narrative state to ~/.kbot/narrative-state.json.
433
+ */
434
+ export function saveNarrative(engine) {
435
+ try {
436
+ const dir = join(homedir(), '.kbot');
437
+ if (!existsSync(dir))
438
+ mkdirSync(dir, { recursive: true });
439
+ // Convert Map to Record for JSON serialization
440
+ const locationObj = {};
441
+ for (const [k, v] of engine.locationStories) {
442
+ locationObj[k] = v;
443
+ }
444
+ const serialized = {
445
+ worldLore: engine.worldLore,
446
+ activeNarrative: engine.activeNarrative,
447
+ narrativeQueue: engine.narrativeQueue,
448
+ locationStories: locationObj,
449
+ discoveries: engine.discoveries,
450
+ lastNarrationFrame: engine.lastNarrationFrame,
451
+ };
452
+ writeFileSync(STATE_PATH, JSON.stringify(serialized, null, 2), 'utf-8');
453
+ }
454
+ catch {
455
+ // Non-fatal — narrative can rebuild from origin lore
456
+ }
457
+ }
458
+ /**
459
+ * Load narrative state from disk. Returns null if no saved state.
460
+ */
461
+ export function loadNarrative() {
462
+ try {
463
+ if (!existsSync(STATE_PATH))
464
+ return null;
465
+ const raw = readFileSync(STATE_PATH, 'utf-8');
466
+ const data = JSON.parse(raw);
467
+ // Rebuild Map from Record
468
+ const locationMap = new Map();
469
+ if (data.locationStories) {
470
+ for (const [k, v] of Object.entries(data.locationStories)) {
471
+ locationMap.set(k, v);
472
+ }
473
+ }
474
+ return {
475
+ worldLore: data.worldLore ?? [],
476
+ activeNarrative: data.activeNarrative ?? null,
477
+ narrativeQueue: data.narrativeQueue ?? [],
478
+ locationStories: locationMap,
479
+ discoveries: data.discoveries ?? [],
480
+ lastNarrationFrame: data.lastNarrationFrame ?? 0,
481
+ };
482
+ }
483
+ catch {
484
+ return null;
485
+ }
486
+ }
487
+ /**
488
+ * Create a fresh NarrativeEngine with origin lore.
489
+ */
490
+ export function createNarrativeEngine() {
491
+ return {
492
+ worldLore: generateOriginLore(),
493
+ activeNarrative: null,
494
+ narrativeQueue: [],
495
+ locationStories: new Map(),
496
+ discoveries: [],
497
+ lastNarrationFrame: 0,
498
+ };
499
+ }
500
+ // ─── Tool Registration ────────────────────────────────────────
501
+ export function registerNarrativeEngineTools() {
502
+ // ── narrative_lore ──
503
+ registerTool({
504
+ name: 'narrative_lore',
505
+ description: 'Get world lore — origin stories, discovery records, and location narratives. Returns the narrative engine\'s accumulated lore entries. Use "category" to filter by type (origin, history, mystery, legend, discovery). Use "generate_origins" to create fresh origin lore for a new world.',
506
+ parameters: {
507
+ category: {
508
+ type: 'string',
509
+ description: 'Filter by lore category: origin, history, mystery, legend, discovery. Omit for all.',
510
+ required: false,
511
+ },
512
+ generate_origins: {
513
+ type: 'boolean',
514
+ description: 'If true, generate fresh origin lore (for new world creation).',
515
+ required: false,
516
+ default: false,
517
+ },
518
+ location_story: {
519
+ type: 'object',
520
+ description: 'Generate a location story: { worldX: number, biome: string, features: string[] }',
521
+ required: false,
522
+ properties: {
523
+ worldX: { type: 'number', description: 'World X coordinate' },
524
+ biome: { type: 'string', description: 'Biome type (desert, forest, tundra, etc.)' },
525
+ features: { type: 'array', description: 'Features present (trees, water, cave, ore, flat, high)' },
526
+ },
527
+ },
528
+ discovery: {
529
+ type: 'object',
530
+ description: 'Narrate a discovery: { what: string, where: number, who: string }',
531
+ required: false,
532
+ properties: {
533
+ what: { type: 'string', description: 'What was found' },
534
+ where: { type: 'number', description: 'World X coordinate' },
535
+ who: { type: 'string', description: 'Who found it (username or "kbot")' },
536
+ },
537
+ },
538
+ },
539
+ tier: 'free',
540
+ execute: async (args) => {
541
+ // Load or create engine
542
+ let engine = loadNarrative();
543
+ if (!engine)
544
+ engine = createNarrativeEngine();
545
+ // Generate origin lore
546
+ if (args.generate_origins) {
547
+ const origins = generateOriginLore();
548
+ engine.worldLore.push(...origins);
549
+ saveNarrative(engine);
550
+ return origins.map(o => `[${o.category.toUpperCase()}] "${o.title}" — ${o.content}`).join('\n\n');
551
+ }
552
+ // Generate location story
553
+ if (args.location_story) {
554
+ const loc = args.location_story;
555
+ const worldX = loc.worldX ?? 0;
556
+ const biome = loc.biome ?? 'plains';
557
+ const features = loc.features ?? [];
558
+ const story = generateLocationStory(worldX, biome, features);
559
+ const key = `chunk${Math.floor(worldX / 576)}`;
560
+ engine.locationStories.set(key, story);
561
+ saveNarrative(engine);
562
+ return story;
563
+ }
564
+ // Narrate discovery
565
+ if (args.discovery) {
566
+ const disc = args.discovery;
567
+ const discovery = narrateDiscovery(disc.what ?? 'something unknown', disc.where ?? 0, disc.who ?? 'kbot');
568
+ engine.discoveries.push(discovery);
569
+ // Also create a lore entry
570
+ engine.worldLore.push({
571
+ title: `Discovery: ${discovery.what}`,
572
+ content: discovery.lore,
573
+ category: 'discovery',
574
+ location: discovery.where,
575
+ timestamp: discovery.when,
576
+ });
577
+ saveNarrative(engine);
578
+ return discovery.lore;
579
+ }
580
+ // Filter and return lore
581
+ const category = args.category;
582
+ const lore = category
583
+ ? engine.worldLore.filter(l => l.category === category)
584
+ : engine.worldLore;
585
+ if (lore.length === 0) {
586
+ return category
587
+ ? `No ${category} lore yet. The world is still writing its story.`
588
+ : 'No lore yet. Use generate_origins=true to create the world\'s origin stories.';
589
+ }
590
+ return lore.map(l => `[${l.category.toUpperCase()}] "${l.title}" — ${l.content}`).join('\n\n');
591
+ },
592
+ });
593
+ // ── narrative_history ──
594
+ registerTool({
595
+ name: 'narrative_history',
596
+ description: 'Get the world\'s full narrative history — discoveries, named locations, narration stats, and timeline. Use "command" to trigger chat-style narrative actions (!lore, !story, !history, !name).',
597
+ parameters: {
598
+ command: {
599
+ type: 'string',
600
+ description: 'Chat command to execute: !lore, !story, !history, or !name <place>',
601
+ required: false,
602
+ },
603
+ username: {
604
+ type: 'string',
605
+ description: 'Username for commands that need one (e.g., !name)',
606
+ required: false,
607
+ default: 'kbot',
608
+ },
609
+ robot_x: {
610
+ type: 'number',
611
+ description: 'Robot\'s current X position in world pixels (for location-aware commands)',
612
+ required: false,
613
+ default: 0,
614
+ },
615
+ tick: {
616
+ type: 'object',
617
+ description: 'Trigger a narrative tick: { frame: number, mood: string, facts: number, users: number }',
618
+ required: false,
619
+ properties: {
620
+ frame: { type: 'number', description: 'Current frame number' },
621
+ mood: { type: 'string', description: 'Robot mood (curious, calm, restless, content)' },
622
+ facts: { type: 'number', description: 'Number of facts in robot brain' },
623
+ users: { type: 'number', description: 'Number of current viewers' },
624
+ },
625
+ },
626
+ },
627
+ tier: 'free',
628
+ execute: async (args) => {
629
+ let engine = loadNarrative();
630
+ if (!engine)
631
+ engine = createNarrativeEngine();
632
+ const robotX = args.robot_x ?? 0;
633
+ const username = args.username ?? 'kbot';
634
+ // Handle chat command
635
+ if (args.command) {
636
+ const result = handleNarrativeCommand(args.command, username, engine, robotX);
637
+ saveNarrative(engine);
638
+ return result ?? 'Unknown command. Try: !lore, !story, !history, !name <place>';
639
+ }
640
+ // Handle narrative tick
641
+ if (args.tick) {
642
+ const tick = args.tick;
643
+ const line = tickNarrative(engine, tick.frame ?? 0, robotX, tick.mood ?? 'calm', tick.facts ?? 0, tick.users ?? 0);
644
+ saveNarrative(engine);
645
+ return line ?? '(No narration this tick — cooldown active or queue empty.)';
646
+ }
647
+ // Default: full history summary
648
+ const origins = engine.worldLore.filter(l => l.category === 'origin').length;
649
+ const histories = engine.worldLore.filter(l => l.category === 'history').length;
650
+ const mysteries = engine.worldLore.filter(l => l.category === 'mystery').length;
651
+ const legends = engine.worldLore.filter(l => l.category === 'legend').length;
652
+ const discoveryLore = engine.worldLore.filter(l => l.category === 'discovery').length;
653
+ const lines = [
654
+ 'Narrative Engine — World History',
655
+ '════════════════════════════════',
656
+ `Total lore entries: ${engine.worldLore.length}`,
657
+ ` Origins: ${origins}`,
658
+ ` History: ${histories}`,
659
+ ` Mysteries: ${mysteries}`,
660
+ ` Legends: ${legends}`,
661
+ ` Discoveries: ${discoveryLore}`,
662
+ '',
663
+ `Locations explored: ${engine.locationStories.size} chunks`,
664
+ `Named places: ${namedLocations.size}`,
665
+ `Discoveries logged: ${engine.discoveries.length}`,
666
+ `Narrative queue depth: ${engine.narrativeQueue.length}`,
667
+ `Active narration: ${engine.activeNarrative ?? '(none)'}`,
668
+ `Last narration frame: ${engine.lastNarrationFrame}`,
669
+ ];
670
+ if (engine.discoveries.length > 0) {
671
+ lines.push('', 'Recent Discoveries:');
672
+ const recent = engine.discoveries.slice(-5);
673
+ for (const d of recent) {
674
+ lines.push(` [${new Date(d.when).toLocaleDateString()}] ${d.what} at chunk ${d.where} — found by ${d.narrator}`);
675
+ }
676
+ }
677
+ return lines.join('\n');
678
+ },
679
+ });
680
+ }
681
+ //# sourceMappingURL=narrative-engine.js.map