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 +1 -1
- package/src/core/handlers/rewrite.ts +18 -1
- package/src/core/orchestrators/ceremony.ts +2 -2
- package/src/core/orchestrators/human-extraction.ts +20 -6
- package/src/core/processor.ts +24 -0
- package/src/core/queue-manager.ts +1 -0
- package/src/core/state-manager.ts +9 -0
- package/src/core/tools/builtin/web-fetch.ts +73 -0
- package/src/core/tools/index.ts +2 -0
- package/src/core/types/data-items.ts +1 -0
- package/src/core/types/integrations.ts +2 -0
- package/src/prompts/response/index.ts +17 -9
- package/tui/src/context/keyboard.tsx +4 -1
package/package.json
CHANGED
|
@@ -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}" —
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
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
|
|
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
|
-
|
|
398
|
-
|
|
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({
|
package/src/core/processor.ts
CHANGED
|
@@ -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 = {
|
|
@@ -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(/ /g, " ")
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/</g, "<")
|
|
50
|
+
.replace(/>/g, ">")
|
|
51
|
+
.replace(/"/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
|
+
};
|
package/src/core/tools/index.ts
CHANGED
|
@@ -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().
|
|
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().
|
|
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(
|
|
122
|
-
|
|
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(
|
|
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
|
}
|