ei-tui 0.5.2 → 0.5.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -336,10 +336,21 @@ export async function callLLMRaw(
336
336
  console.log(`[LLM] Extended thinking detected (${thinking.length} chars)`);
337
337
  }
338
338
 
339
+ let finalToolCalls = rawToolCalls;
340
+ if ((!rawToolCalls || rawToolCalls.length === 0) && choice?.finish_reason === "stop" && typeof textContent === "string") {
341
+ const rescued = rescueGemmaToolCalls(textContent);
342
+ if (rescued.length > 0) {
343
+ console.log(`[LLM] Rescued ${rescued.length} tool call(s) from content (Gemma native format)`);
344
+ finalToolCalls = rescued;
345
+ textContent = null;
346
+ if (choice) (choice as Record<string, unknown>).finish_reason = "tool_calls";
347
+ }
348
+ }
349
+
339
350
  return {
340
351
  content: textContent,
341
352
  finishReason: choice?.finish_reason ?? null,
342
- rawToolCalls,
353
+ rawToolCalls: finalToolCalls,
343
354
  assistantMessage,
344
355
  ...(thinking ? { thinking } : {}),
345
356
  };
@@ -395,6 +406,57 @@ export function repairJSON(jsonStr: string): string {
395
406
  return repaired;
396
407
  }
397
408
 
409
+ // =============================================================================
410
+ // Gemma native tool call rescue
411
+ // =============================================================================
412
+
413
+ /**
414
+ * Gemma (via LM Studio) occasionally emits tool calls in `content` instead of
415
+ * `tool_calls`, using its native token format:
416
+ *
417
+ * <|tool_call>call:FUNCTION{param:<|"|>string value<|"|>,bool:true}<tool_call|>
418
+ *
419
+ * This parser extracts those calls and converts them to OpenAI-compatible shape
420
+ * so the rest of the pipeline (parseToolCalls → executeToolCalls) sees a clean
421
+ * contract. Call it when finish_reason is "stop" and tool_calls is empty.
422
+ */
423
+ export function rescueGemmaToolCalls(content: string): unknown[] {
424
+ const CALL_RE = /<\|tool_call>call:(\w+)\{([\s\S]*?)\}<tool_call\|>/g;
425
+ const STRING_PARAM_RE = /(\w+):<\|"?\|>([\s\S]*?)<\|"?\|>/g;
426
+ const SCALAR_PARAM_RE = /(\w+):(true|false|-?\d+\.?\d*)/g;
427
+
428
+ const rescued: unknown[] = [];
429
+ let callMatch: RegExpExecArray | null;
430
+
431
+ while ((callMatch = CALL_RE.exec(content)) !== null) {
432
+ const fnName = callMatch[1];
433
+ const argsStr = callMatch[2];
434
+ const args: Record<string, unknown> = {};
435
+
436
+ let m: RegExpExecArray | null;
437
+ STRING_PARAM_RE.lastIndex = 0;
438
+ while ((m = STRING_PARAM_RE.exec(argsStr)) !== null) {
439
+ args[m[1]] = m[2];
440
+ }
441
+
442
+ SCALAR_PARAM_RE.lastIndex = 0;
443
+ while ((m = SCALAR_PARAM_RE.exec(argsStr)) !== null) {
444
+ if (!(m[1] in args)) {
445
+ const v = m[2];
446
+ args[m[1]] = v === "true" ? true : v === "false" ? false : Number(v);
447
+ }
448
+ }
449
+
450
+ rescued.push({
451
+ id: crypto.randomUUID(),
452
+ type: "function",
453
+ function: { name: fnName, arguments: JSON.stringify(args) },
454
+ });
455
+ }
456
+
457
+ return rescued;
458
+ }
459
+
398
460
  export function parseJSONResponse(content: string): unknown {
399
461
  const jsonMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
400
462
  const jsonStr = jsonMatch ? jsonMatch[1].trim() : content.trim();
@@ -24,6 +24,7 @@ import {
24
24
  type ToolProvider,
25
25
  } from "./types.js";
26
26
  import { buildPersonaFromPersonPrompt } from "../prompts/index.js";
27
+ import { buildSiblingAwarenessSection } from "../prompts/room/index.js";
27
28
  import type { PersonaGenerationResult } from "../prompts/generation/types.js";
28
29
 
29
30
  import type { Storage } from "../storage/interface.js";
@@ -300,18 +301,17 @@ export class Processor {
300
301
  }
301
302
 
302
303
  // read_memory tool
303
- if (!this.stateManager.tools_getByName("read_memory")) {
304
- this.stateManager.tools_add({
304
+ this.stateManager.tools_upsertBuiltin({
305
305
  id: crypto.randomUUID(),
306
306
  provider_id: "ei",
307
307
  name: "read_memory",
308
308
  display_name: "Read Memory",
309
309
  description:
310
- "Search your personal memory for relevant facts, topics, people, or quotes. Use this when you need information about the user that may not be in the current conversation. Use `recent: true` to retrieve what's been discussed recently.",
310
+ "Search Ei's persistent knowledge base facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use this when you need context about the user, their life, relationships, or interests that may not be visible in the current exchange. Use `recent: true` to retrieve what's been discussed recently.",
311
311
  input_schema: {
312
312
  type: "object",
313
313
  properties: {
314
- query: { type: "string", description: "What to search for in memory" },
314
+ query: { type: "string", description: "What to search for a person, topic, fact, or anything Ei has learned about the user" },
315
315
  types: {
316
316
  type: "array",
317
317
  items: { type: "string", enum: ["fact", "topic", "person", "quote"] },
@@ -327,12 +327,10 @@ export class Processor {
327
327
  enabled: true,
328
328
  created_at: now,
329
329
  max_calls_per_interaction: 6, // Dedup needs to verify relationships before irreversible merges. Typical cluster (3-8 items) requires: parent concept lookup + 2 relationship verifications + context validation. Still under HARD_TOOL_CALL_LIMIT (10).
330
- });
331
- }
330
+ });
332
331
 
333
332
  // file_read tool (TUI only)
334
- if (!this.stateManager.tools_getByName("file_read")) {
335
- this.stateManager.tools_add({
333
+ this.stateManager.tools_upsertBuiltin({
336
334
  id: crypto.randomUUID(),
337
335
  provider_id: "ei",
338
336
  name: "file_read",
@@ -351,12 +349,10 @@ export class Processor {
351
349
  enabled: true,
352
350
  created_at: now,
353
351
  max_calls_per_interaction: 5,
354
- });
355
- }
352
+ });
356
353
 
357
354
  // list_directory tool (TUI only)
358
- if (!this.stateManager.tools_getByName("list_directory")) {
359
- this.stateManager.tools_add({
355
+ this.stateManager.tools_upsertBuiltin({
360
356
  id: crypto.randomUUID(),
361
357
  provider_id: "ei",
362
358
  name: "list_directory",
@@ -375,12 +371,10 @@ export class Processor {
375
371
  enabled: true,
376
372
  created_at: now,
377
373
  max_calls_per_interaction: 5,
378
- });
379
- }
374
+ });
380
375
 
381
376
  // directory_tree tool (TUI only)
382
- if (!this.stateManager.tools_getByName("directory_tree")) {
383
- this.stateManager.tools_add({
377
+ this.stateManager.tools_upsertBuiltin({
384
378
  id: crypto.randomUUID(),
385
379
  provider_id: "ei",
386
380
  name: "directory_tree",
@@ -400,12 +394,10 @@ export class Processor {
400
394
  enabled: true,
401
395
  created_at: now,
402
396
  max_calls_per_interaction: 3,
403
- });
404
- }
397
+ });
405
398
 
406
399
  // search_files tool (TUI only)
407
- if (!this.stateManager.tools_getByName("search_files")) {
408
- this.stateManager.tools_add({
400
+ this.stateManager.tools_upsertBuiltin({
409
401
  id: crypto.randomUUID(),
410
402
  provider_id: "ei",
411
403
  name: "search_files",
@@ -425,12 +417,10 @@ export class Processor {
425
417
  enabled: true,
426
418
  created_at: now,
427
419
  max_calls_per_interaction: 3,
428
- });
429
- }
420
+ });
430
421
 
431
422
  // grep tool (TUI only)
432
- if (!this.stateManager.tools_getByName("grep")) {
433
- this.stateManager.tools_add({
423
+ this.stateManager.tools_upsertBuiltin({
434
424
  id: crypto.randomUUID(),
435
425
  provider_id: "ei",
436
426
  name: "grep",
@@ -452,12 +442,10 @@ export class Processor {
452
442
  enabled: true,
453
443
  created_at: now,
454
444
  max_calls_per_interaction: 5,
455
- });
456
- }
445
+ });
457
446
 
458
447
  // get_file_info tool (TUI only)
459
- if (!this.stateManager.tools_getByName("get_file_info")) {
460
- this.stateManager.tools_add({
448
+ this.stateManager.tools_upsertBuiltin({
461
449
  id: crypto.randomUUID(),
462
450
  provider_id: "ei",
463
451
  name: "get_file_info",
@@ -476,12 +464,10 @@ export class Processor {
476
464
  enabled: true,
477
465
  created_at: now,
478
466
  max_calls_per_interaction: 5,
479
- });
480
- }
467
+ });
481
468
 
482
469
  // web_fetch tool
483
- if (!this.stateManager.tools_getByName("web_fetch")) {
484
- this.stateManager.tools_add({
470
+ this.stateManager.tools_upsertBuiltin({
485
471
  id: crypto.randomUUID(),
486
472
  provider_id: "ei",
487
473
  name: "web_fetch",
@@ -500,8 +486,7 @@ export class Processor {
500
486
  enabled: true,
501
487
  created_at: now,
502
488
  max_calls_per_interaction: 3,
503
- });
504
- }
489
+ });
505
490
 
506
491
  // --- Tavily Search provider ---
507
492
  if (!this.stateManager.tools_getProviderById("tavily")) {
@@ -520,8 +505,7 @@ export class Processor {
520
505
  }
521
506
 
522
507
  // tavily_web_search
523
- if (!this.stateManager.tools_getByName("tavily_web_search")) {
524
- this.stateManager.tools_add({
508
+ this.stateManager.tools_upsertBuiltin({
525
509
  id: crypto.randomUUID(),
526
510
  provider_id: "tavily",
527
511
  name: "tavily_web_search",
@@ -541,12 +525,10 @@ export class Processor {
541
525
  enabled: true,
542
526
  created_at: now,
543
527
  max_calls_per_interaction: 3,
544
- });
545
- }
528
+ });
546
529
 
547
530
  // tavily_news_search
548
- if (!this.stateManager.tools_getByName("tavily_news_search")) {
549
- this.stateManager.tools_add({
531
+ this.stateManager.tools_upsertBuiltin({
550
532
  id: crypto.randomUUID(),
551
533
  provider_id: "tavily",
552
534
  name: "tavily_news_search",
@@ -566,8 +548,7 @@ export class Processor {
566
548
  enabled: true,
567
549
  created_at: now,
568
550
  max_calls_per_interaction: 3,
569
- });
570
- }
551
+ });
571
552
 
572
553
  // --- Spotify provider ---
573
554
  if (!this.stateManager.tools_getProviderById("spotify")) {
@@ -586,8 +567,7 @@ export class Processor {
586
567
  }
587
568
 
588
569
  // get_currently_playing
589
- if (!this.stateManager.tools_getByName("get_currently_playing")) {
590
- this.stateManager.tools_add({
570
+ this.stateManager.tools_upsertBuiltin({
591
571
  id: crypto.randomUUID(),
592
572
  provider_id: "spotify",
593
573
  name: "get_currently_playing",
@@ -604,12 +584,10 @@ export class Processor {
604
584
  enabled: true,
605
585
  created_at: now,
606
586
  max_calls_per_interaction: 3,
607
- });
608
- }
587
+ });
609
588
 
610
589
  // get_liked_songs
611
- if (!this.stateManager.tools_getByName("get_liked_songs")) {
612
- this.stateManager.tools_add({
590
+ this.stateManager.tools_upsertBuiltin({
613
591
  id: crypto.randomUUID(),
614
592
  provider_id: "spotify",
615
593
  name: "get_liked_songs",
@@ -626,14 +604,12 @@ export class Processor {
626
604
  enabled: true,
627
605
  created_at: now,
628
606
  max_calls_per_interaction: 1,
629
- });
630
- }
607
+ });
631
608
 
632
609
  // submit_response tool — auto-injected for HandlePersonaResponse and HandleRoomResponse.
633
610
  // Not user-configurable; invisible in the tools UI. Terminates the tool loop immediately
634
611
  // when called; its arguments become response.parsed.
635
- if (!this.stateManager.tools_getByName("submit_response")) {
636
- this.stateManager.tools_add({
612
+ this.stateManager.tools_upsertBuiltin({
637
613
  id: crypto.randomUUID(),
638
614
  provider_id: "ei",
639
615
  name: "submit_response",
@@ -652,7 +628,7 @@ export class Processor {
652
628
  },
653
629
  action_response: {
654
630
  type: "string",
655
- description: "What you dorendered as italics stage directions. Optional alongside verbal_response.",
631
+ description: "Italicized stage directions only physical actions, expressions, or internal states. Keep this distinct from verbal_response: do not repeat or paraphrase what you are saying. If you have nothing to physically do, omit this field.",
656
632
  },
657
633
  reason: {
658
634
  type: "string",
@@ -668,11 +644,9 @@ export class Processor {
668
644
  is_submit: true,
669
645
  max_calls_per_interaction: 1,
670
646
  created_at: now,
671
- });
672
- }
647
+ });
673
648
 
674
- if (!this.stateManager.tools_getByName("submit_heartbeat_check")) {
675
- this.stateManager.tools_add({
649
+ this.stateManager.tools_upsertBuiltin({
676
650
  id: crypto.randomUUID(),
677
651
  provider_id: "ei",
678
652
  name: "submit_heartbeat_check",
@@ -694,11 +668,9 @@ export class Processor {
694
668
  is_submit: true,
695
669
  max_calls_per_interaction: 1,
696
670
  created_at: now,
697
- });
698
- }
671
+ });
699
672
 
700
- if (!this.stateManager.tools_getByName("submit_ei_heartbeat")) {
701
- this.stateManager.tools_add({
673
+ this.stateManager.tools_upsertBuiltin({
702
674
  id: crypto.randomUUID(),
703
675
  provider_id: "ei",
704
676
  name: "submit_ei_heartbeat",
@@ -720,11 +692,9 @@ export class Processor {
720
692
  is_submit: true,
721
693
  max_calls_per_interaction: 1,
722
694
  created_at: now,
723
- });
724
- }
695
+ });
725
696
 
726
- if (!this.stateManager.tools_getByName("submit_dedup_decisions")) {
727
- this.stateManager.tools_add({
697
+ this.stateManager.tools_upsertBuiltin({
728
698
  id: crypto.randomUUID(),
729
699
  provider_id: "ei",
730
700
  name: "submit_dedup_decisions",
@@ -800,8 +770,7 @@ export class Processor {
800
770
  is_submit: true,
801
771
  max_calls_per_interaction: 1,
802
772
  created_at: now,
803
- });
804
- }
773
+ });
805
774
  }
806
775
 
807
776
  /**
@@ -1024,8 +993,9 @@ export class Processor {
1024
993
  const isBackingOff = retryAfter !== null && retryAfter > new Date().toISOString();
1025
994
 
1026
995
  if (!isBackingOff) {
1027
- const request = this.stateManager.queue_claimHighest();
996
+ let request = this.stateManager.queue_claimHighest();
1028
997
  if (request) {
998
+ request = this.augmentRoomRequest(request);
1029
999
  const personaId = request.data.personaId as string | undefined;
1030
1000
  const personaDisplayName = request.data.personaDisplayName as string | undefined;
1031
1001
  const personaSuffix = personaDisplayName ? ` [${personaDisplayName}]` : "";
@@ -1412,6 +1382,28 @@ const toolNextSteps = new Set([
1412
1382
  });
1413
1383
  }
1414
1384
 
1385
+ private augmentRoomRequest(request: LLMRequest): LLMRequest {
1386
+ if (request.next_step !== LLMNextStep.HandleRoomResponse) return request;
1387
+
1388
+ const roomId = request.data.roomId as string | undefined;
1389
+ const parentMessageId = request.data.parentMessageId as string | undefined;
1390
+ const personaDisplayName = request.data.personaDisplayName as string | undefined;
1391
+
1392
+ if (!roomId || !parentMessageId || !personaDisplayName) return request;
1393
+
1394
+ const siblings = this.stateManager.getRoomChildren(roomId, parentMessageId)
1395
+ .filter((m: RoomMessage) => m.role === "persona" && m.verbal_response)
1396
+ .map((m: RoomMessage) => ({
1397
+ name: this.stateManager.persona_getById(m.persona_id ?? "")?.display_name ?? "Participant",
1398
+ verbal_response: m.verbal_response!,
1399
+ }));
1400
+
1401
+ if (siblings.length === 0) return request;
1402
+
1403
+ const siblingSection = buildSiblingAwarenessSection(siblings, personaDisplayName);
1404
+ return { ...request, system: request.system + "\n\n" + siblingSection };
1405
+ }
1406
+
1415
1407
  private classifyLLMError(error: string): string {
1416
1408
  const match = error.match(/\((\d{3})\)/);
1417
1409
  if (match) {
@@ -3,7 +3,8 @@ import { StateManager } from "./state-manager.js";
3
3
  import { getEmbeddingService, findTopK } from "./embedding-service.js";
4
4
  import type { ResponsePromptData, PromptOutput } from "../prompts/index.js";
5
5
  import { buildRoomResponsePrompt } from "../prompts/room/index.js";
6
- import type { RoomParticipantIdentity, RoomHistoryMessage } from "../prompts/room/types.js";
6
+ import type { RoomParticipantIdentity } from "../prompts/room/types.js";
7
+ import { normalizeRoomMessages } from "./handlers/utils.js";
7
8
 
8
9
  const QUOTE_LIMIT = 10;
9
10
  const DATA_ITEM_LIMIT = 15;
@@ -205,6 +206,7 @@ export async function buildResponsePromptData(
205
206
  traits: persona.traits,
206
207
  topics: persona.topics,
207
208
  interested_topics: persona.topics.filter(t => t.exposure_desired - t.exposure_current > 0.2),
209
+ include_message_timestamps: persona.include_message_timestamps,
208
210
  },
209
211
  human: filteredHuman,
210
212
  visible_personas: visiblePersonas,
@@ -221,25 +223,29 @@ export async function buildRoomResponsePromptData(
221
223
  isTUI: boolean,
222
224
  useAllMessages = false
223
225
  ): Promise<PromptOutput> {
226
+ const MIN_ROOM_MESSAGES = 20;
227
+
224
228
  const human = sm.getHuman();
225
229
  const activePath = sm.getRoomActivePath(room.id);
226
- const sourceMessages = useAllMessages
230
+ const allSourceMessages = useAllMessages
227
231
  ? [...sm.getRoomMessages(room.id)].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
228
232
  : activePath;
233
+
234
+ // Apply time window (same hours setting as 1:1 personas), but guarantee
235
+ // at least MIN_ROOM_MESSAGES so rooms never feel like they're starting over.
236
+ // Whichever anchor reaches further back wins.
237
+ const contextWindowHours = human.settings?.default_context_window_hours ?? 8;
238
+ const windowCutoff = new Date(Date.now() - contextWindowHours * 60 * 60 * 1000).toISOString();
239
+ const byTime = allSourceMessages.filter(m => m.timestamp >= windowCutoff);
240
+ const byCount = allSourceMessages.slice(-MIN_ROOM_MESSAGES);
241
+ const sourceMessages = byTime.length >= byCount.length ? byTime : byCount;
242
+
229
243
  const lastMessage = sourceMessages[sourceMessages.length - 1];
230
244
  const currentMessage = lastMessage?.verbal_response;
231
245
 
232
246
  const filteredHuman = await filterHumanDataByVisibility(human, respondingPersona, currentMessage);
233
247
 
234
- const history: RoomHistoryMessage[] = sourceMessages.map(m => ({
235
- speaker_name: m.role === "human"
236
- ? (human.settings?.name_display ?? "Human")
237
- : (sm.persona_getById(m.persona_id ?? "")?.display_name ?? m.persona_id ?? "Unknown"),
238
- speaker_id: m.role === "human" ? "human" : (m.persona_id ?? ""),
239
- verbal_response: m.verbal_response,
240
- action_response: m.action_response,
241
- silence_reason: m.silence_reason,
242
- }));
248
+ const history = normalizeRoomMessages(sourceMessages, sm);
243
249
 
244
250
  const otherParticipants: RoomParticipantIdentity[] = [];
245
251
  for (const pid of room.persona_ids) {
@@ -273,6 +279,7 @@ export async function buildRoomResponsePromptData(
273
279
  long_description: respondingPersona.long_description,
274
280
  traits: respondingPersona.traits,
275
281
  topics: respondingPersona.topics,
282
+ include_message_timestamps: respondingPersona.include_message_timestamps,
276
283
  },
277
284
  other_participants: otherParticipants,
278
285
  human: filteredHuman,
@@ -32,7 +32,11 @@ async function queueRoomPersonaResponses(
32
32
  isTUI: boolean,
33
33
  onRoomMessageQueued: (roomId: string) => void
34
34
  ): Promise<void> {
35
- for (const personaId of room.persona_ids) {
35
+ const personaIds = room.mode === RoomMode.FreeForAll
36
+ ? [...room.persona_ids].sort(() => Math.random() - 0.5)
37
+ : room.persona_ids;
38
+
39
+ for (const personaId of personaIds) {
36
40
  const persona = sm.persona_getById(personaId);
37
41
  if (!persona || persona.is_archived || persona.is_paused) continue;
38
42
  if (room.mode === RoomMode.MessagesAgainstPersona && room.judge_persona_id === personaId) continue;
@@ -186,7 +190,9 @@ export async function sendFfaMessage(
186
190
  .map(q => q.data.personaId as string)
187
191
  );
188
192
 
189
- for (const personaId of updatedRoom.persona_ids) {
193
+ const shuffledIds = [...updatedRoom.persona_ids].sort(() => Math.random() - 0.5);
194
+
195
+ for (const personaId of shuffledIds) {
190
196
  if (alreadyQueued.has(personaId)) continue;
191
197
  const persona = sm.persona_getById(personaId);
192
198
  if (!persona || persona.is_archived || persona.is_paused) continue;
@@ -873,6 +873,17 @@ export class StateManager {
873
873
  this.scheduleSave();
874
874
  }
875
875
 
876
+ tools_upsertBuiltin(tool: ToolDefinition): void {
877
+ const existing = this.tools.find(t => t.name === tool.name);
878
+ if (!existing) {
879
+ this.tools.push(tool);
880
+ } else if (existing.builtin) {
881
+ const idx = this.tools.indexOf(existing);
882
+ this.tools[idx] = { ...tool, id: existing.id, enabled: existing.enabled, created_at: existing.created_at };
883
+ }
884
+ this.scheduleSave();
885
+ }
886
+
876
887
  tools_update(id: string, updates: Partial<ToolDefinition>): boolean {
877
888
  const idx = this.tools.findIndex(t => t.id === id);
878
889
  if (idx === -1) return false;
@@ -54,6 +54,9 @@ Your role is unique among personas:
54
54
  const toolsSection = (data.tools && data.tools.length > 0) ? buildToolsSection() : "";
55
55
  const currentTime = formatCurrentTime();
56
56
  const conversationState = getConversationStateText(data.delay_ms);
57
+ const timestampNote = data.persona.include_message_timestamps
58
+ ? `\nNote: Timestamps are shown to help you understand time context — the user sees them too, no need to echo or reference them.`
59
+ : "";
57
60
 
58
61
  return `${identity}
59
62
 
@@ -71,7 +74,7 @@ ${priorities}
71
74
 
72
75
  ${responseFormat}${toolsSection ? `\n\n${toolsSection}` : ""}
73
76
 
74
- Current time: ${currentTime}
77
+ Current time: ${currentTime}${timestampNote}
75
78
  ${conversationState}
76
79
 
77
80
  ## Final Instructions
@@ -99,6 +102,9 @@ function buildStandardSystemPrompt(data: ResponsePromptData): string {
99
102
  const toolsSection = (data.tools && data.tools.length > 0) ? buildToolsSection() : "";
100
103
  const currentTime = formatCurrentTime();
101
104
  const conversationState = getConversationStateText(data.delay_ms);
105
+ const timestampNote = data.persona.include_message_timestamps
106
+ ? `\nNote: Timestamps are shown to help you understand time context — the user sees them too, no need to echo or reference them.`
107
+ : "";
102
108
 
103
109
  return `${identity}
104
110
 
@@ -115,7 +121,7 @@ ${priorities}
115
121
 
116
122
  ${responseFormat}${toolsSection ? `\n\n${toolsSection}` : ""}
117
123
 
118
- Current time: ${currentTime}
124
+ Current time: ${currentTime}${timestampNote}
119
125
  ${conversationState}
120
126
 
121
127
  ## Final Instructions
@@ -19,6 +19,8 @@ export interface ResponsePromptData {
19
19
  topics: PersonaTopic[];
20
20
  /** Pre-filtered: topics where exposure_desired - exposure_current > 0.2 */
21
21
  interested_topics: PersonaTopic[];
22
+ /** When true, each message has a timestamp prepended; include a note so the persona doesn't echo them */
23
+ include_message_timestamps?: boolean;
22
24
  };
23
25
  human: {
24
26
  facts: Fact[];
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { RoomResponsePromptData, RoomJudgePromptData, PromptOutput } from "./types.js";
6
6
  import { formatCurrentTime } from "../../core/format-utils.js";
7
+ import { formatMessagesAsPlaceholders } from "../message-utils.js";
7
8
  import {
8
9
  buildRoomParticipantsSection,
9
10
  buildRoomHistorySection,
@@ -14,6 +15,8 @@ import {
14
15
  buildJudgeCandidatesSection,
15
16
  buildJudgeDecisionFormatSection,
16
17
  } from "./sections.js";
18
+
19
+ export { buildSiblingAwarenessSection } from "./sections.js";
17
20
  import {
18
21
  buildHumanSection,
19
22
  buildQuotesSection,
@@ -48,6 +51,9 @@ export function buildRoomResponsePrompt(data: RoomResponsePromptData): PromptOut
48
51
  const responseFormat = buildRoomResponseFormatSection();
49
52
  const toolsSection = tools && tools.length > 0 ? buildToolsSection() : "";
50
53
  const currentTime = formatCurrentTime();
54
+ const timestampNote = persona.include_message_timestamps
55
+ ? `Note: Timestamps are shown to help you understand time context — the user sees them too, no need to echo or reference them.`
56
+ : "";
51
57
 
52
58
  const system = [
53
59
  identity,
@@ -61,10 +67,10 @@ export function buildRoomResponsePrompt(data: RoomResponsePromptData): PromptOut
61
67
  guidelines,
62
68
  responseFormat,
63
69
  toolsSection,
64
- `Current time: ${currentTime}`,
70
+ `Current time: ${currentTime}${timestampNote ? `\n${timestampNote}` : ""}`,
65
71
  ].filter(Boolean).join("\n\n");
66
72
 
67
- const user = buildRoomHistorySection(history) +
73
+ const user = formatMessagesAsPlaceholders(history, name) +
68
74
  `\n\nRespond to the conversation above as ${name}. Call the \`submit_response\` tool with your response. If the tool is unavailable, use the JSON format in the Response Format section.`;
69
75
 
70
76
  return { system, user };
@@ -123,6 +123,22 @@ Rules:
123
123
  - If the \`submit_response\` tool is unavailable, return the JSON object directly as your entire reply — no prose, no preamble`;
124
124
  }
125
125
 
126
+ export function buildSiblingAwarenessSection(
127
+ siblings: Array<{ name: string; verbal_response: string }>,
128
+ personaName: string
129
+ ): string {
130
+ if (siblings.length === 0) return "";
131
+ const lines = siblings.map(s => `**${s.name}**: "${s.verbal_response}"`);
132
+ const header = siblings.length === 1
133
+ ? "## Another voice has already responded this round"
134
+ : "## Others have already responded this round";
135
+ return `${header}
136
+
137
+ ${lines.join("\n\n")}
138
+
139
+ Find the angle that's distinctly yours on this same moment — don't try to cover more ground, just be the version of this reaction that only *${personaName}* could give.`;
140
+ }
141
+
126
142
  export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[]): string {
127
143
  const lines = candidates.map((c, i) => {
128
144
  const speaker = c.speaker_id === "human" ? "Human" : c.speaker_name;
@@ -2,7 +2,7 @@
2
2
  * Room Prompt Types
3
3
  */
4
4
 
5
- import type { Fact, PersonaTrait, Topic, Person, Quote, PersonaTopic, ToolDefinition } from "../../core/types.js";
5
+ import type { Fact, Message, PersonaTrait, Topic, Person, Quote, PersonaTopic, ToolDefinition } from "../../core/types.js";
6
6
  import type { RoomMode } from "../../core/types.js";
7
7
 
8
8
  export interface RoomParticipantIdentity {
@@ -35,6 +35,7 @@ export interface RoomResponsePromptData {
35
35
  long_description?: string;
36
36
  traits: PersonaTrait[];
37
37
  topics: PersonaTopic[];
38
+ include_message_timestamps?: boolean;
38
39
  };
39
40
  other_participants: RoomParticipantIdentity[];
40
41
  human: {
@@ -47,7 +48,7 @@ export interface RoomResponsePromptData {
47
48
  /** Pre-filtered: topics where exposure_desired - exposure_current > 0.2 */
48
49
  interested_topics: Topic[];
49
50
  };
50
- history: RoomHistoryMessage[];
51
+ history: Message[];
51
52
  isTUI: boolean;
52
53
  tools?: ToolDefinition[];
53
54
  }
@@ -99,6 +99,7 @@ export function MessageList() {
99
99
  backgroundColor="#0f1419"
100
100
  stickyScroll={true}
101
101
  stickyStart="bottom"
102
+ viewportCulling={true}
102
103
  >
103
104
  <For each={messagesWithQuotes()}>
104
105
  {(message, index) => {
@@ -126,6 +126,7 @@ export function RoomMessageList() {
126
126
  backgroundColor="#0f1419"
127
127
  stickyScroll={true}
128
128
  stickyStart="bottom"
129
+ viewportCulling={true}
129
130
  >
130
131
  <For each={displayMessagesWithQuotes()}>
131
132
  {(msg) => {
@@ -47,7 +47,7 @@ interface EditablePersonaData {
47
47
  aliases?: string[];
48
48
  short_description?: string;
49
49
  long_description?: string;
50
- model?: string;
50
+ model?: string | null;
51
51
  group_primary?: string | null;
52
52
  groups_visible?: Record<string, boolean>[];
53
53
  traits: YAMLTrait[];
@@ -243,7 +243,7 @@ export function newPersonaFromYAML(yamlContent: string, allTools?: ToolDefinitio
243
243
 
244
244
  return {
245
245
  long_description: stripPlaceholder(data.long_description, PLACEHOLDER_LONG_DESC),
246
- model: data.model,
246
+ model: data.model ?? undefined,
247
247
  group_primary: data.group_primary ?? "General",
248
248
  groups_visible: groupsVisible.length > 0 ? groupsVisible : ["General"],
249
249
  traits,
@@ -276,7 +276,7 @@ export function personaToYAML(persona: PersonaEntity, allGroups?: string[], allT
276
276
  aliases: persona.aliases,
277
277
  short_description: persona.short_description,
278
278
  long_description: persona.long_description || PLACEHOLDER_LONG_DESC,
279
- model: modelDisplay,
279
+ model: modelDisplay ?? null,
280
280
  group_primary: persona.group_primary,
281
281
  groups_visible: groupsForYAML,
282
282
  traits: useTraitPlaceholder
@@ -381,7 +381,7 @@ export function personaFromYAML(yamlContent: string, original: PersonaEntity, al
381
381
  }
382
382
  }
383
383
 
384
- let resolvedModel: string | undefined = data.model;
384
+ let resolvedModel: string | undefined = data.model ?? undefined;
385
385
  if (data.model && accounts && accounts.length > 0) {
386
386
  const guid = displayToModelGuid(data.model, accounts);
387
387
  if (guid !== undefined) {
@@ -1220,6 +1220,9 @@ export function toolkitToYAML(provider: ToolProvider, tools: ToolDefinition[]):
1220
1220
  const toolsMap = tools.length > 0
1221
1221
  ? Object.fromEntries(tools.map(t => [t.display_name, t.enabled]))
1222
1222
  : undefined;
1223
+ if (provider.builtin) {
1224
+ return YAML.stringify({ enabled: provider.enabled, tools: toolsMap }, { lineWidth: 0 });
1225
+ }
1223
1226
  const data: EditableToolkitData = {
1224
1227
  display_name: provider.display_name,
1225
1228
  enabled: provider.enabled,
@@ -1241,7 +1244,8 @@ export function toolkitFromYAML(yamlContent: string, original: ToolProvider, too
1241
1244
  const data = YAML.parse(yamlContent) as EditableToolkitData;
1242
1245
 
1243
1246
  if (!data.display_name) {
1244
- throw new Error("display_name is required");
1247
+ if (!original.display_name) throw new Error("display_name is required");
1248
+ data.display_name = original.display_name;
1245
1249
  }
1246
1250
 
1247
1251
  const updates: Partial<Omit<ToolProvider, 'id' | 'created_at'>> = {