ei-tui 0.5.3 → 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.3",
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();
@@ -301,18 +301,17 @@ export class Processor {
301
301
  }
302
302
 
303
303
  // read_memory tool
304
- if (!this.stateManager.tools_getByName("read_memory")) {
305
- this.stateManager.tools_add({
304
+ this.stateManager.tools_upsertBuiltin({
306
305
  id: crypto.randomUUID(),
307
306
  provider_id: "ei",
308
307
  name: "read_memory",
309
308
  display_name: "Read Memory",
310
309
  description:
311
- "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.",
312
311
  input_schema: {
313
312
  type: "object",
314
313
  properties: {
315
- 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" },
316
315
  types: {
317
316
  type: "array",
318
317
  items: { type: "string", enum: ["fact", "topic", "person", "quote"] },
@@ -328,12 +327,10 @@ export class Processor {
328
327
  enabled: true,
329
328
  created_at: now,
330
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).
331
- });
332
- }
330
+ });
333
331
 
334
332
  // file_read tool (TUI only)
335
- if (!this.stateManager.tools_getByName("file_read")) {
336
- this.stateManager.tools_add({
333
+ this.stateManager.tools_upsertBuiltin({
337
334
  id: crypto.randomUUID(),
338
335
  provider_id: "ei",
339
336
  name: "file_read",
@@ -352,12 +349,10 @@ export class Processor {
352
349
  enabled: true,
353
350
  created_at: now,
354
351
  max_calls_per_interaction: 5,
355
- });
356
- }
352
+ });
357
353
 
358
354
  // list_directory tool (TUI only)
359
- if (!this.stateManager.tools_getByName("list_directory")) {
360
- this.stateManager.tools_add({
355
+ this.stateManager.tools_upsertBuiltin({
361
356
  id: crypto.randomUUID(),
362
357
  provider_id: "ei",
363
358
  name: "list_directory",
@@ -376,12 +371,10 @@ export class Processor {
376
371
  enabled: true,
377
372
  created_at: now,
378
373
  max_calls_per_interaction: 5,
379
- });
380
- }
374
+ });
381
375
 
382
376
  // directory_tree tool (TUI only)
383
- if (!this.stateManager.tools_getByName("directory_tree")) {
384
- this.stateManager.tools_add({
377
+ this.stateManager.tools_upsertBuiltin({
385
378
  id: crypto.randomUUID(),
386
379
  provider_id: "ei",
387
380
  name: "directory_tree",
@@ -401,12 +394,10 @@ export class Processor {
401
394
  enabled: true,
402
395
  created_at: now,
403
396
  max_calls_per_interaction: 3,
404
- });
405
- }
397
+ });
406
398
 
407
399
  // search_files tool (TUI only)
408
- if (!this.stateManager.tools_getByName("search_files")) {
409
- this.stateManager.tools_add({
400
+ this.stateManager.tools_upsertBuiltin({
410
401
  id: crypto.randomUUID(),
411
402
  provider_id: "ei",
412
403
  name: "search_files",
@@ -426,12 +417,10 @@ export class Processor {
426
417
  enabled: true,
427
418
  created_at: now,
428
419
  max_calls_per_interaction: 3,
429
- });
430
- }
420
+ });
431
421
 
432
422
  // grep tool (TUI only)
433
- if (!this.stateManager.tools_getByName("grep")) {
434
- this.stateManager.tools_add({
423
+ this.stateManager.tools_upsertBuiltin({
435
424
  id: crypto.randomUUID(),
436
425
  provider_id: "ei",
437
426
  name: "grep",
@@ -453,12 +442,10 @@ export class Processor {
453
442
  enabled: true,
454
443
  created_at: now,
455
444
  max_calls_per_interaction: 5,
456
- });
457
- }
445
+ });
458
446
 
459
447
  // get_file_info tool (TUI only)
460
- if (!this.stateManager.tools_getByName("get_file_info")) {
461
- this.stateManager.tools_add({
448
+ this.stateManager.tools_upsertBuiltin({
462
449
  id: crypto.randomUUID(),
463
450
  provider_id: "ei",
464
451
  name: "get_file_info",
@@ -477,12 +464,10 @@ export class Processor {
477
464
  enabled: true,
478
465
  created_at: now,
479
466
  max_calls_per_interaction: 5,
480
- });
481
- }
467
+ });
482
468
 
483
469
  // web_fetch tool
484
- if (!this.stateManager.tools_getByName("web_fetch")) {
485
- this.stateManager.tools_add({
470
+ this.stateManager.tools_upsertBuiltin({
486
471
  id: crypto.randomUUID(),
487
472
  provider_id: "ei",
488
473
  name: "web_fetch",
@@ -501,8 +486,7 @@ export class Processor {
501
486
  enabled: true,
502
487
  created_at: now,
503
488
  max_calls_per_interaction: 3,
504
- });
505
- }
489
+ });
506
490
 
507
491
  // --- Tavily Search provider ---
508
492
  if (!this.stateManager.tools_getProviderById("tavily")) {
@@ -521,8 +505,7 @@ export class Processor {
521
505
  }
522
506
 
523
507
  // tavily_web_search
524
- if (!this.stateManager.tools_getByName("tavily_web_search")) {
525
- this.stateManager.tools_add({
508
+ this.stateManager.tools_upsertBuiltin({
526
509
  id: crypto.randomUUID(),
527
510
  provider_id: "tavily",
528
511
  name: "tavily_web_search",
@@ -542,12 +525,10 @@ export class Processor {
542
525
  enabled: true,
543
526
  created_at: now,
544
527
  max_calls_per_interaction: 3,
545
- });
546
- }
528
+ });
547
529
 
548
530
  // tavily_news_search
549
- if (!this.stateManager.tools_getByName("tavily_news_search")) {
550
- this.stateManager.tools_add({
531
+ this.stateManager.tools_upsertBuiltin({
551
532
  id: crypto.randomUUID(),
552
533
  provider_id: "tavily",
553
534
  name: "tavily_news_search",
@@ -567,8 +548,7 @@ export class Processor {
567
548
  enabled: true,
568
549
  created_at: now,
569
550
  max_calls_per_interaction: 3,
570
- });
571
- }
551
+ });
572
552
 
573
553
  // --- Spotify provider ---
574
554
  if (!this.stateManager.tools_getProviderById("spotify")) {
@@ -587,8 +567,7 @@ export class Processor {
587
567
  }
588
568
 
589
569
  // get_currently_playing
590
- if (!this.stateManager.tools_getByName("get_currently_playing")) {
591
- this.stateManager.tools_add({
570
+ this.stateManager.tools_upsertBuiltin({
592
571
  id: crypto.randomUUID(),
593
572
  provider_id: "spotify",
594
573
  name: "get_currently_playing",
@@ -605,12 +584,10 @@ export class Processor {
605
584
  enabled: true,
606
585
  created_at: now,
607
586
  max_calls_per_interaction: 3,
608
- });
609
- }
587
+ });
610
588
 
611
589
  // get_liked_songs
612
- if (!this.stateManager.tools_getByName("get_liked_songs")) {
613
- this.stateManager.tools_add({
590
+ this.stateManager.tools_upsertBuiltin({
614
591
  id: crypto.randomUUID(),
615
592
  provider_id: "spotify",
616
593
  name: "get_liked_songs",
@@ -627,14 +604,12 @@ export class Processor {
627
604
  enabled: true,
628
605
  created_at: now,
629
606
  max_calls_per_interaction: 1,
630
- });
631
- }
607
+ });
632
608
 
633
609
  // submit_response tool — auto-injected for HandlePersonaResponse and HandleRoomResponse.
634
610
  // Not user-configurable; invisible in the tools UI. Terminates the tool loop immediately
635
611
  // when called; its arguments become response.parsed.
636
- if (!this.stateManager.tools_getByName("submit_response")) {
637
- this.stateManager.tools_add({
612
+ this.stateManager.tools_upsertBuiltin({
638
613
  id: crypto.randomUUID(),
639
614
  provider_id: "ei",
640
615
  name: "submit_response",
@@ -653,7 +628,7 @@ export class Processor {
653
628
  },
654
629
  action_response: {
655
630
  type: "string",
656
- 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.",
657
632
  },
658
633
  reason: {
659
634
  type: "string",
@@ -669,11 +644,9 @@ export class Processor {
669
644
  is_submit: true,
670
645
  max_calls_per_interaction: 1,
671
646
  created_at: now,
672
- });
673
- }
647
+ });
674
648
 
675
- if (!this.stateManager.tools_getByName("submit_heartbeat_check")) {
676
- this.stateManager.tools_add({
649
+ this.stateManager.tools_upsertBuiltin({
677
650
  id: crypto.randomUUID(),
678
651
  provider_id: "ei",
679
652
  name: "submit_heartbeat_check",
@@ -695,11 +668,9 @@ export class Processor {
695
668
  is_submit: true,
696
669
  max_calls_per_interaction: 1,
697
670
  created_at: now,
698
- });
699
- }
671
+ });
700
672
 
701
- if (!this.stateManager.tools_getByName("submit_ei_heartbeat")) {
702
- this.stateManager.tools_add({
673
+ this.stateManager.tools_upsertBuiltin({
703
674
  id: crypto.randomUUID(),
704
675
  provider_id: "ei",
705
676
  name: "submit_ei_heartbeat",
@@ -721,11 +692,9 @@ export class Processor {
721
692
  is_submit: true,
722
693
  max_calls_per_interaction: 1,
723
694
  created_at: now,
724
- });
725
- }
695
+ });
726
696
 
727
- if (!this.stateManager.tools_getByName("submit_dedup_decisions")) {
728
- this.stateManager.tools_add({
697
+ this.stateManager.tools_upsertBuiltin({
729
698
  id: crypto.randomUUID(),
730
699
  provider_id: "ei",
731
700
  name: "submit_dedup_decisions",
@@ -801,8 +770,7 @@ export class Processor {
801
770
  is_submit: true,
802
771
  max_calls_per_interaction: 1,
803
772
  created_at: now,
804
- });
805
- }
773
+ });
806
774
  }
807
775
 
808
776
  /**
@@ -223,11 +223,23 @@ export async function buildRoomResponsePromptData(
223
223
  isTUI: boolean,
224
224
  useAllMessages = false
225
225
  ): Promise<PromptOutput> {
226
+ const MIN_ROOM_MESSAGES = 20;
227
+
226
228
  const human = sm.getHuman();
227
229
  const activePath = sm.getRoomActivePath(room.id);
228
- const sourceMessages = useAllMessages
230
+ const allSourceMessages = useAllMessages
229
231
  ? [...sm.getRoomMessages(room.id)].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
230
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
+
231
243
  const lastMessage = sourceMessages[sourceMessages.length - 1];
232
244
  const currentMessage = lastMessage?.verbal_response;
233
245
 
@@ -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;
@@ -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'>> = {