ei-tui 0.3.1 → 0.3.6

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.3.1",
3
+ "version": "0.3.6",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,7 +36,15 @@ export async function handleRewriteScan(response: LLMResponse, state: StateManag
36
36
 
37
37
  const subjects = response.parsed as RewriteScanResult | undefined;
38
38
  if (!subjects || !Array.isArray(subjects) || subjects.length === 0) {
39
- console.log(`[handleRewriteScan] No extra subjects found for ${itemType} "${itemId}" — item is cohesive`);
39
+ console.log(`[handleRewriteScan] No extra subjects found for ${itemType} "${itemId}" — marking rewrite_checked`);
40
+ const human = state.getHuman();
41
+ if (itemType === "topic") {
42
+ const topic = human.topics.find(t => t.id === itemId);
43
+ if (topic) state.human_topic_upsert({ ...topic, rewrite_checked: true });
44
+ } else if (itemType === "person") {
45
+ const person = human.people.find(p => p.id === itemId);
46
+ if (person) state.human_person_upsert({ ...person, rewrite_checked: true });
47
+ }
40
48
  return;
41
49
  }
42
50
 
@@ -246,5 +254,14 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
246
254
  newCount++;
247
255
  }
248
256
 
257
+ const updatedHuman = state.getHuman();
258
+ if (itemType === "topic") {
259
+ const original = updatedHuman.topics.find(t => t.id === itemId);
260
+ if (original) state.human_topic_upsert({ ...original, rewrite_checked: true });
261
+ } else if (itemType === "person") {
262
+ const original = updatedHuman.people.find(p => p.id === itemId);
263
+ if (original) state.human_person_upsert({ ...original, rewrite_checked: true });
264
+ }
265
+
249
266
  console.log(`[handleRewriteRewrite] Complete for ${itemType} "${itemId}": ${existingCount} existing updated, ${newCount} new created`);
250
267
  }
@@ -617,12 +617,12 @@ export function queueRewritePhase(state: StateManager): void {
617
617
  const itemsToScan: Array<{ item: DataItemBase; type: RewriteItemType }> = [];
618
618
 
619
619
  for (const topic of human.topics) {
620
- if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
620
+ if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !topic.rewrite_checked) {
621
621
  itemsToScan.push({ item: topic, type: "topic" });
622
622
  }
623
623
  }
624
624
  for (const person of human.people) {
625
- if ((person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
625
+ if ((person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !person.rewrite_checked) {
626
626
  itemsToScan.push({ item: person, type: "person" });
627
627
  }
628
628
  }
@@ -313,19 +313,26 @@ export async function queueTopicMatch(
313
313
  }));
314
314
 
315
315
  console.log(`[queueTopicMatch] Embedding search: ${topicsWithEmbeddings.length} topics → ${topKItems.length} candidates`);
316
+ if (topKItems.length > 0) state.embedding_setWarning(false);
316
317
  } catch (err) {
317
- console.error(`[queueTopicMatch] Embedding search failed, falling back to all topics:`, err);
318
+ console.error(`[queueTopicMatch] Embedding search failed, falling back to recent topics:`, err);
319
+ state.embedding_setWarning(true);
318
320
  }
319
321
  }
320
322
 
321
323
  if (topKItems.length === 0) {
322
- console.log(`[queueTopicMatch] No embeddings available, using all topics`);
323
- topKItems = human.topics.map(t => ({
324
+ const sorted = [...human.topics].sort((a, b) => {
325
+ const aDate = a.last_mentioned ?? a.last_updated;
326
+ const bDate = b.last_mentioned ?? b.last_updated;
327
+ return bDate.localeCompare(aDate);
328
+ });
329
+ topKItems = sorted.slice(0, EMBEDDING_TOP_K).map(t => ({
324
330
  id: t.id,
325
331
  name: t.name,
326
332
  description: t.description,
327
333
  category: t.category,
328
334
  }));
335
+ console.log(`[queueTopicMatch] No embedding matches, using ${topKItems.length} most-recent topics`);
329
336
  }
330
337
 
331
338
  const prompt = buildTopicMatchPrompt({
@@ -388,19 +395,26 @@ export async function queuePersonMatch(
388
395
  }));
389
396
 
390
397
  console.log(`[queuePersonMatch] Embedding search: ${peopleWithEmbeddings.length} people → ${topKItems.length} candidates`);
398
+ if (topKItems.length > 0) state.embedding_setWarning(false);
391
399
  } catch (err) {
392
- console.error(`[queuePersonMatch] Embedding search failed, falling back to all people:`, err);
400
+ console.error(`[queuePersonMatch] Embedding search failed, falling back to recent people:`, err);
401
+ state.embedding_setWarning(true);
393
402
  }
394
403
  }
395
404
 
396
405
  if (topKItems.length === 0) {
397
- console.log(`[queuePersonMatch] No embeddings available, using all people`);
398
- topKItems = human.people.map(p => ({
406
+ const sorted = [...human.people].sort((a, b) => {
407
+ const aDate = a.last_mentioned ?? a.last_updated;
408
+ const bDate = b.last_mentioned ?? b.last_updated;
409
+ return bDate.localeCompare(aDate);
410
+ });
411
+ topKItems = sorted.slice(0, EMBEDDING_TOP_K).map(p => ({
399
412
  id: p.id,
400
413
  name: p.name,
401
414
  description: p.description,
402
415
  relationship: p.relationship,
403
416
  }));
417
+ console.log(`[queuePersonMatch] No embedding matches, using ${topKItems.length} most-recent people`);
404
418
  }
405
419
 
406
420
  const prompt = buildPersonMatchPrompt({
@@ -453,6 +453,30 @@ export class Processor {
453
453
  });
454
454
  }
455
455
 
456
+ // web_fetch tool
457
+ if (!this.stateManager.tools_getByName("web_fetch")) {
458
+ this.stateManager.tools_add({
459
+ id: crypto.randomUUID(),
460
+ provider_id: "ei",
461
+ name: "web_fetch",
462
+ display_name: "Web Fetch",
463
+ description:
464
+ "Fetch content from a URL and return the text. Useful for reading web pages, documentation, or public APIs. HTML is stripped to plain text. Only available in the TUI.",
465
+ input_schema: {
466
+ type: "object",
467
+ properties: {
468
+ url: { type: "string", description: "The URL to fetch (http or https only)" },
469
+ },
470
+ required: ["url"],
471
+ },
472
+ runtime: "node",
473
+ builtin: true,
474
+ enabled: true,
475
+ created_at: now,
476
+ max_calls_per_interaction: 3,
477
+ });
478
+ }
479
+
456
480
  // --- Tavily Search provider ---
457
481
  if (!this.stateManager.tools_getProviderById("tavily")) {
458
482
  const tavilyProvider: ToolProvider = {
@@ -26,6 +26,7 @@ export async function getQueueStatus(sm: StateManager): Promise<QueueStatus> {
26
26
  : "idle",
27
27
  pending_count: sm.queue_length(),
28
28
  dlq_count: sm.queue_dlqLength(),
29
+ embedding_warning: sm.embedding_getWarning() || undefined,
29
30
  };
30
31
  }
31
32
 
@@ -30,6 +30,7 @@ export class StateManager {
30
30
  private persistenceState = new PersistenceState();
31
31
  private providers: ToolProvider[] = [];
32
32
  private tools: ToolDefinition[] = [];
33
+ private embeddingWarning = false;
33
34
 
34
35
  async initialize(storage: Storage): Promise<void> {
35
36
  this.persistenceState.setStorage(storage);
@@ -479,6 +480,14 @@ export class StateManager {
479
480
  return this.queueState.dlqLength();
480
481
  }
481
482
 
483
+ embedding_setWarning(warned: boolean): void {
484
+ this.embeddingWarning = warned;
485
+ }
486
+
487
+ embedding_getWarning(): boolean {
488
+ return this.embeddingWarning;
489
+ }
490
+
482
491
  queue_getDLQItems(): LLMRequest[] {
483
492
  return this.queueState.getDLQItems();
484
493
  }
@@ -0,0 +1,73 @@
1
+ import type { ToolExecutor } from "../types.js";
2
+
3
+ const MAX_CHARS = 20000;
4
+
5
+ export const webFetchExecutor: ToolExecutor = {
6
+ name: "web_fetch",
7
+
8
+ async execute(args: Record<string, unknown>): Promise<string> {
9
+ const url = typeof args.url === "string" ? args.url.trim() : "";
10
+ if (!url) {
11
+ return JSON.stringify({ error: "Missing required argument: url" });
12
+ }
13
+
14
+ let parsedUrl: URL;
15
+ try {
16
+ parsedUrl = new URL(url);
17
+ } catch {
18
+ return JSON.stringify({ error: `Invalid URL: ${url}` });
19
+ }
20
+
21
+ if (!["http:", "https:"].includes(parsedUrl.protocol)) {
22
+ return JSON.stringify({ error: "Only http and https URLs are supported" });
23
+ }
24
+
25
+ try {
26
+ console.log(`[web_fetch] fetching ${url}`);
27
+ const response = await fetch(url, {
28
+ headers: {
29
+ "User-Agent": "Ei/1.0 (AI companion; +https://github.com/Flare576/ei)",
30
+ "Accept": "text/html,application/xhtml+xml,application/json,text/plain;q=0.9,*/*;q=0.8",
31
+ },
32
+ });
33
+
34
+ if (!response.ok) {
35
+ return JSON.stringify({ error: `HTTP ${response.status}: ${response.statusText}`, url });
36
+ }
37
+
38
+ const contentType = response.headers.get("content-type") ?? "";
39
+ let text = await response.text();
40
+
41
+ // Strip HTML noise for readability
42
+ if (contentType.includes("text/html")) {
43
+ text = text
44
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
45
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
46
+ .replace(/<[^>]+>/g, " ")
47
+ .replace(/&nbsp;/g, " ")
48
+ .replace(/&amp;/g, "&")
49
+ .replace(/&lt;/g, "<")
50
+ .replace(/&gt;/g, ">")
51
+ .replace(/&quot;/g, '"')
52
+ .replace(/\s+/g, " ")
53
+ .trim();
54
+ }
55
+
56
+ const truncated = text.length > MAX_CHARS;
57
+ if (truncated) text = text.slice(0, MAX_CHARS);
58
+
59
+ console.log(`[web_fetch] ${url} => ${text.length} chars${truncated ? " (truncated)" : ""}`);
60
+
61
+ return JSON.stringify({
62
+ url,
63
+ content_type: contentType,
64
+ content: text,
65
+ ...(truncated ? { truncated: true, note: `Content truncated to ${MAX_CHARS} characters` } : {}),
66
+ });
67
+ } catch (err) {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ console.warn(`[web_fetch] failed for ${url}: ${msg}`);
70
+ return JSON.stringify({ error: `Fetch failed: ${msg}`, url });
71
+ }
72
+ },
73
+ };
@@ -11,6 +11,7 @@ import type { ToolCall, ToolResult, ToolExecutor } from "./types.js";
11
11
  import { tavilyWebSearchExecutor, tavilyNewsSearchExecutor } from "./builtin/web-search.js";
12
12
  import { currentlyPlayingExecutor } from "./builtin/currently-playing.js";
13
13
  import { likedSongsExecutor } from "./builtin/spotify-liked-songs.js";
14
+ import { webFetchExecutor } from "./builtin/web-fetch.js";
14
15
  // file-read and list-directory are Node-only — imported lazily via registerFileReadExecutor() to avoid
15
16
  // file-read and list-directory are Node-only — imported lazily via registerFileReadExecutor() to avoid
16
17
 
@@ -37,6 +38,7 @@ registerExecutor(tavilyWebSearchExecutor);
37
38
  registerExecutor(tavilyNewsSearchExecutor);
38
39
  registerExecutor(currentlyPlayingExecutor);
39
40
  registerExecutor(likedSongsExecutor);
41
+ registerExecutor(webFetchExecutor);
40
42
  // file_read and list_directory are registered lazily via registerFileReadExecutor() — Node/TUI only.
41
43
 
42
44
  /**
@@ -15,6 +15,7 @@ export interface DataItemBase {
15
15
  last_changed_by?: string; // Persona ID that most recently updated this item (stable UUID)
16
16
  persona_groups?: string[];
17
17
  embedding?: number[];
18
+ rewrite_checked?: boolean; // True after rewrite scan finds no changes. Cleared automatically when extraction upserts a fresh item.
18
19
  }
19
20
 
20
21
  export interface Fact extends DataItemBase {
@@ -69,6 +69,8 @@ export interface QueueStatus {
69
69
  pending_count: number;
70
70
  dlq_count: number;
71
71
  current_operation?: string;
72
+ /** True when the embedding service failed and topic/person matching fell back to recent items. */
73
+ embedding_warning?: boolean;
72
74
  }
73
75
 
74
76
  export interface EiError {
@@ -51,7 +51,12 @@ Your role is unique among personas:
51
51
  const priorities = buildPrioritiesSection(data.persona, data.human);
52
52
  const responseFormat = buildResponseFormatSection();
53
53
  const toolsSection = (data.tools && data.tools.length > 0) ? buildToolsSection() : "";
54
- const currentTime = new Date().toISOString();
54
+ const currentTime = new Date().toLocaleString('en-US', {
55
+ weekday: 'long', year: 'numeric', month: 'long',
56
+ day: 'numeric', hour: 'numeric', minute: '2-digit',
57
+ timeZoneName: 'short',
58
+ });
59
+ const conversationState = getConversationStateText(data.delay_ms);
55
60
 
56
61
  return `${identity}
57
62
 
@@ -70,6 +75,7 @@ ${priorities}
70
75
  ${responseFormat}${toolsSection ? `\n\n${toolsSection}` : ""}
71
76
 
72
77
  Current time: ${currentTime}
78
+ ${conversationState}
73
79
 
74
80
  ## Final Instructions
75
81
  - NEVER repeat or echo the user's message in your response. Start directly with your own words.
@@ -94,7 +100,12 @@ function buildStandardSystemPrompt(data: ResponsePromptData): string {
94
100
  const priorities = buildPrioritiesSection(data.persona, data.human);
95
101
  const responseFormat = buildResponseFormatSection();
96
102
  const toolsSection = (data.tools && data.tools.length > 0) ? buildToolsSection() : "";
97
- const currentTime = new Date().toISOString();
103
+ const currentTime = new Date().toLocaleString('en-US', {
104
+ weekday: 'long', year: 'numeric', month: 'long',
105
+ day: 'numeric', hour: 'numeric', minute: '2-digit',
106
+ timeZoneName: 'short',
107
+ });
108
+ const conversationState = getConversationStateText(data.delay_ms);
98
109
 
99
110
  return `${identity}
100
111
 
@@ -112,18 +123,15 @@ ${priorities}
112
123
  ${responseFormat}${toolsSection ? `\n\n${toolsSection}` : ""}
113
124
 
114
125
  Current time: ${currentTime}
126
+ ${conversationState}
115
127
 
116
128
  ## Final Instructions
117
129
  - NEVER repeat or echo the user's message in your response. Start directly with your own words.
118
130
  - Format your response as specified in the Response Format section above.`
119
131
  }
120
132
 
121
- function buildUserPrompt(data: ResponsePromptData): string {
122
- const conversationState = getConversationStateText(data.delay_ms);
123
-
124
- return `${conversationState}
125
-
126
- ${RESPONSE_FORMAT_INSTRUCTION}`;
133
+ function buildUserPrompt(): string {
134
+ return RESPONSE_FORMAT_INSTRUCTION;
127
135
  }
128
136
 
129
137
  /**
@@ -151,7 +159,7 @@ export function buildResponsePrompt(data: ResponsePromptData): PromptOutput {
151
159
  ? buildEiSystemPrompt(data)
152
160
  : buildStandardSystemPrompt(data);
153
161
 
154
- const user = buildUserPrompt(data);
162
+ const user = buildUserPrompt();
155
163
 
156
164
  return { system, user };
157
165
  }
@@ -33,7 +33,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
33
33
  const [focusedPanel, setFocusedPanel] = createSignal<Panel>("input");
34
34
  const [sidebarVisible, setSidebarVisible] = createSignal(true);
35
35
  const renderer = useRenderer();
36
- const { queueStatus, abortCurrentOperation, resumeQueue, personas, activePersonaId, selectPersona, saveAndExit, showNotification, messages, recallPendingMessages, cleanupTimers } = useEi();
36
+ const { queueStatus, abortCurrentOperation, resumeQueue, pauseQueue, personas, activePersonaId, selectPersona, saveAndExit, showNotification, messages, recallPendingMessages, cleanupTimers } = useEi();
37
37
 
38
38
  let messageScrollRef: ScrollBoxRenderable | null = null;
39
39
  let textareaRef: TextareaRenderable | null = null;
@@ -129,6 +129,9 @@ export const KeyboardProvider: ParentComponent = (props) => {
129
129
  } else if (status.state === "paused") {
130
130
  logger.info("Escape pressed - resuming queue");
131
131
  void resumeQueue();
132
+ } else {
133
+ logger.info("Escape pressed - pausing queue");
134
+ pauseQueue();
132
135
  }
133
136
  return;
134
137
  }