ei-tui 1.0.0 → 1.1.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.
- package/README.md +3 -1
- package/package.json +3 -1
- package/src/cli/README.md +42 -14
- package/src/cli/mcp.ts +237 -0
- package/src/cli.ts +17 -51
- package/src/core/handlers/human-extraction.ts +22 -16
- package/src/core/handlers/human-matching.ts +45 -10
- package/src/core/llm-client.ts +101 -15
- package/src/core/orchestrators/human-extraction.ts +28 -0
- package/src/core/orchestrators/index.ts +1 -0
- package/src/core/processor.ts +37 -41
- package/src/core/prompt-context-builder.ts +1 -0
- package/src/core/queue-processor.ts +26 -17
- package/src/core/state-manager.ts +6 -6
- package/src/core/tools/builtin/fetch-memory.ts +92 -0
- package/src/core/tools/builtin/fetch-message.ts +123 -0
- package/src/core/tools/builtin/find-memory.ts +99 -0
- package/src/core/tools/index.ts +88 -5
- package/src/integrations/persona-history/importer.ts +3 -1
- package/src/prompts/ceremony/dedup.ts +3 -3
- package/src/prompts/ceremony/types.ts +1 -1
- package/src/prompts/human/person-scan.ts +17 -0
- package/src/prompts/human/types.ts +4 -0
- package/src/prompts/response/sections.ts +14 -7
- package/src/prompts/response/types.ts +1 -0
- package/tui/README.md +3 -2
- package/tui/src/util/logger.ts +1 -1
- package/src/core/tools/builtin/read-memory.ts +0 -70
|
@@ -1054,7 +1054,7 @@ export class StateManager {
|
|
|
1054
1054
|
tools_getForPersona(personaId: string, isTUI: boolean): ToolDefinition[] {
|
|
1055
1055
|
const persona = this.personaState.getById(personaId);
|
|
1056
1056
|
if (!persona?.tools?.length) {
|
|
1057
|
-
console.
|
|
1057
|
+
console.debug(`[Tools] tools_getForPersona(${personaId}): persona has no assigned tools`);
|
|
1058
1058
|
return [];
|
|
1059
1059
|
}
|
|
1060
1060
|
const assignedIds = new Set(persona.tools);
|
|
@@ -1077,13 +1077,13 @@ export class StateManager {
|
|
|
1077
1077
|
if (result.length < assignedIds.size) {
|
|
1078
1078
|
for (const id of assignedIds) {
|
|
1079
1079
|
const tool = this.tools.find(t => t.id === id);
|
|
1080
|
-
if (!tool) { console.
|
|
1081
|
-
if (!tool.enabled) { console.
|
|
1082
|
-
if (!enabledProviderIds.has(tool.provider_id)) { console.
|
|
1083
|
-
if (!(tool.runtime === "any" || (tool.runtime === "node" && isTUI))) { console.
|
|
1080
|
+
if (!tool) { console.debug(`[Tools] tools_getForPersona: assigned tool id=${id} not found in registry`); continue; }
|
|
1081
|
+
if (!tool.enabled) { console.debug(`[Tools] tools_getForPersona: tool "${tool.name}" is disabled`); continue; }
|
|
1082
|
+
if (!enabledProviderIds.has(tool.provider_id)) { console.debug(`[Tools] tools_getForPersona: tool "${tool.name}" provider is disabled`); continue; }
|
|
1083
|
+
if (!(tool.runtime === "any" || (tool.runtime === "node" && isTUI))) { console.debug(`[Tools] tools_getForPersona: tool "${tool.name}" runtime "${tool.runtime}" not available (isTUI=${isTUI})`); continue; }
|
|
1084
1084
|
}
|
|
1085
1085
|
}
|
|
1086
|
-
console.
|
|
1086
|
+
console.debug(`[Tools] tools_getForPersona(${personaId}): resolved ${result.length}/${assignedIds.size} tools: [${result.map(t => t.name).join(", ")}]`);
|
|
1087
1087
|
return result;
|
|
1088
1088
|
}
|
|
1089
1089
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { ToolExecutor } from "../types.js";
|
|
2
|
+
import type { Fact, Topic, Person, Quote, HumanEntity } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
type GetHuman = () => HumanEntity;
|
|
5
|
+
|
|
6
|
+
function cleanFact(f: Fact): Record<string, unknown> {
|
|
7
|
+
const { embedding, rewrite_checked, persona_groups, ...rest } = f;
|
|
8
|
+
void embedding; void rewrite_checked; void persona_groups;
|
|
9
|
+
return rest;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function cleanTopic(t: Topic): Record<string, unknown> {
|
|
13
|
+
const { embedding, rewrite_checked, persona_groups, last_ei_asked, ...rest } = t;
|
|
14
|
+
void embedding; void rewrite_checked; void persona_groups; void last_ei_asked;
|
|
15
|
+
return rest;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cleanPerson(p: Person): Record<string, unknown> {
|
|
19
|
+
const { embedding, rewrite_checked, persona_groups, last_ei_asked, ...rest } = p;
|
|
20
|
+
void embedding; void rewrite_checked; void persona_groups; void last_ei_asked;
|
|
21
|
+
return rest;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function cleanQuote(
|
|
25
|
+
q: Quote,
|
|
26
|
+
facts: Fact[],
|
|
27
|
+
topics: Topic[],
|
|
28
|
+
people: Person[]
|
|
29
|
+
): Record<string, unknown> {
|
|
30
|
+
const { embedding, persona_groups, data_item_ids, ...rest } = q;
|
|
31
|
+
void embedding; void persona_groups;
|
|
32
|
+
|
|
33
|
+
const linked_items: Array<{ id: string; name: string; type: string }> = [];
|
|
34
|
+
for (const id of data_item_ids) {
|
|
35
|
+
const fact = facts.find(f => f.id === id);
|
|
36
|
+
if (fact) { linked_items.push({ id: fact.id, name: fact.name, type: "fact" }); continue; }
|
|
37
|
+
const topic = topics.find(t => t.id === id);
|
|
38
|
+
if (topic) { linked_items.push({ id: topic.id, name: topic.name, type: "topic" }); continue; }
|
|
39
|
+
const person = people.find(p => p.id === id);
|
|
40
|
+
if (person) { linked_items.push({ id: person.id, name: person.name, type: "person" }); continue; }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { ...rest, linked_items };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createFetchMemoryExecutor(getHuman: GetHuman): ToolExecutor {
|
|
47
|
+
return {
|
|
48
|
+
name: "fetch_memory",
|
|
49
|
+
|
|
50
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
51
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
52
|
+
console.log(`[fetch_memory] called with id="${id}"`);
|
|
53
|
+
|
|
54
|
+
if (!id) {
|
|
55
|
+
console.warn("[fetch_memory] missing id argument");
|
|
56
|
+
return JSON.stringify({ error: "Missing required argument: id" });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const human = getHuman();
|
|
60
|
+
|
|
61
|
+
const fact = human.facts.find(f => f.id === id);
|
|
62
|
+
if (fact) {
|
|
63
|
+
console.log(`[fetch_memory] found fact id="${id}" name="${fact.name}"`);
|
|
64
|
+
return JSON.stringify({ type: "fact", ...cleanFact(fact) });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const topic = human.topics.find(t => t.id === id);
|
|
68
|
+
if (topic) {
|
|
69
|
+
console.log(`[fetch_memory] found topic id="${id}" name="${topic.name}"`);
|
|
70
|
+
return JSON.stringify({ type: "topic", ...cleanTopic(topic) });
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const person = human.people.find(p => p.id === id);
|
|
74
|
+
if (person) {
|
|
75
|
+
console.log(`[fetch_memory] found person id="${id}" name="${person.name}"`);
|
|
76
|
+
return JSON.stringify({ type: "person", ...cleanPerson(person) });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const quote = human.quotes.find(q => q.id === id);
|
|
80
|
+
if (quote) {
|
|
81
|
+
console.log(`[fetch_memory] found quote id="${id}"`);
|
|
82
|
+
return JSON.stringify({
|
|
83
|
+
type: "quote",
|
|
84
|
+
...cleanQuote(quote, human.facts, human.topics, human.people),
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
console.log(`[fetch_memory] no entity found for id="${id}"`);
|
|
89
|
+
return JSON.stringify({ error: "No accessible record found for this ID" });
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { ToolExecutor } from "../types.js";
|
|
2
|
+
import type { Message } from "../../types.js";
|
|
3
|
+
import type { RoomMessage, RoomSummary } from "../../types/rooms.js";
|
|
4
|
+
import type { PersonaEntity } from "../../types/entities.js";
|
|
5
|
+
|
|
6
|
+
interface CleanMessage {
|
|
7
|
+
id: string;
|
|
8
|
+
role: string;
|
|
9
|
+
content?: string;
|
|
10
|
+
silence_reason?: string;
|
|
11
|
+
timestamp: string;
|
|
12
|
+
speaker_name?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type GetAllPersonas = () => PersonaEntity[];
|
|
16
|
+
type GetPersonaMessages = (personaId: string) => Message[];
|
|
17
|
+
type GetRoomList = () => RoomSummary[];
|
|
18
|
+
type GetRoomMessages = (roomId: string) => RoomMessage[];
|
|
19
|
+
type GetRoomDisplayName = (roomId: string) => string | null;
|
|
20
|
+
|
|
21
|
+
function stripMessage(m: Message): CleanMessage {
|
|
22
|
+
return {
|
|
23
|
+
id: m.id,
|
|
24
|
+
role: m.role,
|
|
25
|
+
...(m.content !== undefined ? { content: m.content } : {}),
|
|
26
|
+
...(m.silence_reason !== undefined ? { silence_reason: m.silence_reason } : {}),
|
|
27
|
+
timestamp: m.timestamp,
|
|
28
|
+
...(m.speaker_name !== undefined ? { speaker_name: m.speaker_name } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stripRoomMessage(m: RoomMessage, personaDisplayName?: string): CleanMessage {
|
|
33
|
+
return {
|
|
34
|
+
id: m.id,
|
|
35
|
+
role: m.role,
|
|
36
|
+
...(m.content !== undefined ? { content: m.content } : {}),
|
|
37
|
+
...(m.silence_reason !== undefined ? { silence_reason: m.silence_reason } : {}),
|
|
38
|
+
timestamp: m.timestamp,
|
|
39
|
+
...(personaDisplayName !== undefined ? { speaker_name: personaDisplayName } : {}),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createFetchMessageExecutor(
|
|
44
|
+
getAllPersonas: GetAllPersonas,
|
|
45
|
+
getPersonaMessages: GetPersonaMessages,
|
|
46
|
+
getRoomList: GetRoomList,
|
|
47
|
+
getRoomMessages: GetRoomMessages,
|
|
48
|
+
getRoomDisplayName: GetRoomDisplayName
|
|
49
|
+
): ToolExecutor {
|
|
50
|
+
return {
|
|
51
|
+
name: "fetch_message",
|
|
52
|
+
|
|
53
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
54
|
+
const id = typeof args.id === "string" ? args.id.trim() : "";
|
|
55
|
+
const before = typeof args.before === "number" && args.before > 0 ? Math.floor(args.before) : 0;
|
|
56
|
+
const after = typeof args.after === "number" && args.after > 0 ? Math.floor(args.after) : 0;
|
|
57
|
+
|
|
58
|
+
console.log(`[fetch_message] called with id="${id}", before=${before}, after=${after}`);
|
|
59
|
+
|
|
60
|
+
if (!id) {
|
|
61
|
+
console.warn("[fetch_message] missing id argument");
|
|
62
|
+
return JSON.stringify({ error: "Missing required argument: id" });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const personas = getAllPersonas();
|
|
66
|
+
|
|
67
|
+
// TODO: add persona access gate when calling context is available —
|
|
68
|
+
// the execute() signature has no requestingPersonaId, so we search all personas.
|
|
69
|
+
for (const persona of personas) {
|
|
70
|
+
const messages = getPersonaMessages(persona.id);
|
|
71
|
+
const idx = messages.findIndex(m => m.id === id);
|
|
72
|
+
if (idx === -1) continue;
|
|
73
|
+
|
|
74
|
+
const msg = messages[idx];
|
|
75
|
+
const beforeMsgs = messages.slice(Math.max(0, idx - before), idx).map(stripMessage);
|
|
76
|
+
const afterMsgs = messages.slice(idx + 1, idx + 1 + after).map(stripMessage);
|
|
77
|
+
|
|
78
|
+
console.log(`[fetch_message] found in persona "${persona.display_name}" at idx=${idx}`);
|
|
79
|
+
return JSON.stringify({
|
|
80
|
+
message: stripMessage(msg),
|
|
81
|
+
before: beforeMsgs,
|
|
82
|
+
after: afterMsgs,
|
|
83
|
+
persona: persona.display_name,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// TODO: add persona access gate when calling context is available.
|
|
88
|
+
const rooms = getRoomList();
|
|
89
|
+
for (const roomSummary of rooms) {
|
|
90
|
+
const messages = getRoomMessages(roomSummary.id);
|
|
91
|
+
const idx = messages.findIndex(m => m.id === id);
|
|
92
|
+
if (idx === -1) continue;
|
|
93
|
+
|
|
94
|
+
const msg = messages[idx];
|
|
95
|
+
const roomDisplayName = getRoomDisplayName(roomSummary.id) ?? roomSummary.display_name;
|
|
96
|
+
|
|
97
|
+
const resolvePersonaName = (m: RoomMessage): string | undefined => {
|
|
98
|
+
if (m.role !== "persona" || !m.persona_id) return undefined;
|
|
99
|
+
const p = personas.find(pe => pe.id === m.persona_id);
|
|
100
|
+
return p?.display_name;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const beforeMsgs = messages
|
|
104
|
+
.slice(Math.max(0, idx - before), idx)
|
|
105
|
+
.map(m => stripRoomMessage(m, resolvePersonaName(m)));
|
|
106
|
+
const afterMsgs = messages
|
|
107
|
+
.slice(idx + 1, idx + 1 + after)
|
|
108
|
+
.map(m => stripRoomMessage(m, resolvePersonaName(m)));
|
|
109
|
+
|
|
110
|
+
console.log(`[fetch_message] found in room "${roomDisplayName}" at idx=${idx}`);
|
|
111
|
+
return JSON.stringify({
|
|
112
|
+
message: stripRoomMessage(msg, resolvePersonaName(msg)),
|
|
113
|
+
before: beforeMsgs,
|
|
114
|
+
after: afterMsgs,
|
|
115
|
+
persona: roomDisplayName,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`[fetch_message] message not found for id="${id}"`);
|
|
120
|
+
return JSON.stringify({ error: "Message not found" });
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { ToolExecutor } from "../types.js";
|
|
2
|
+
import type { Fact, Topic, Person, Quote, HumanEntity } from "../../types.js";
|
|
3
|
+
|
|
4
|
+
interface PersonaSummary {
|
|
5
|
+
id: string;
|
|
6
|
+
display_name: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type SearchHumanData = (
|
|
10
|
+
query: string,
|
|
11
|
+
options?: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean; persona_filter?: string }
|
|
12
|
+
) => Promise<{ facts: Fact[]; topics: Topic[]; people: Person[]; quotes: Quote[] }>;
|
|
13
|
+
|
|
14
|
+
type GetPersonaList = () => Promise<PersonaSummary[]>;
|
|
15
|
+
|
|
16
|
+
type GetHuman = () => HumanEntity;
|
|
17
|
+
|
|
18
|
+
export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPersonaList?: GetPersonaList, getHuman?: GetHuman): ToolExecutor {
|
|
19
|
+
return {
|
|
20
|
+
name: "find_memory",
|
|
21
|
+
|
|
22
|
+
async execute(args: Record<string, unknown>): Promise<string> {
|
|
23
|
+
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
24
|
+
const recent = args.recent === true;
|
|
25
|
+
const personaArg = typeof args.persona === "string" ? args.persona.trim() : "";
|
|
26
|
+
console.log(`[find_memory] called with query="${query}", types=${JSON.stringify(args.types ?? null)}, limit=${args.limit ?? 10}, recent=${recent}, persona="${personaArg}"`);
|
|
27
|
+
|
|
28
|
+
if (!query && !recent) {
|
|
29
|
+
console.warn("[find_memory] missing query argument");
|
|
30
|
+
return JSON.stringify({ error: "Missing required argument: query (or use recent: true)" });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const typeMap: Record<string, "fact" | "topic" | "person" | "quote"> = {
|
|
34
|
+
fact: "fact", facts: "fact",
|
|
35
|
+
topic: "topic", topics: "topic",
|
|
36
|
+
person: "person", people: "person",
|
|
37
|
+
quote: "quote", quotes: "quote",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const types = Array.isArray(args.types)
|
|
41
|
+
? (args.types
|
|
42
|
+
.filter((t): t is string => typeof t === "string")
|
|
43
|
+
.map(t => typeMap[t])
|
|
44
|
+
.filter((t): t is "fact" | "topic" | "person" | "quote" => t !== undefined)
|
|
45
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
46
|
+
)
|
|
47
|
+
: undefined;
|
|
48
|
+
|
|
49
|
+
const limit = typeof args.limit === "number" && args.limit > 0 ? Math.min(args.limit, 20) : 10;
|
|
50
|
+
|
|
51
|
+
// Resolve persona display_name to ID
|
|
52
|
+
let persona_filter: string | undefined;
|
|
53
|
+
if (personaArg && getPersonaList) {
|
|
54
|
+
const personas = await getPersonaList();
|
|
55
|
+
const match = personas.find(p => p.display_name.toLowerCase() === personaArg.toLowerCase());
|
|
56
|
+
if (match) {
|
|
57
|
+
persona_filter = match.id;
|
|
58
|
+
console.log(`[find_memory] resolved persona "${personaArg}" to ID "${persona_filter}"`);
|
|
59
|
+
} else {
|
|
60
|
+
console.warn(`[find_memory] persona "${personaArg}" not found, proceeding without filter`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const results = await searchHumanData(query, { types, limit, recent, persona_filter });
|
|
65
|
+
|
|
66
|
+
const total = results.facts.length + results.topics.length + results.people.length + results.quotes.length;
|
|
67
|
+
console.log(`[find_memory] query="${query}" => ${total} results (facts=${results.facts.length}, topics=${results.topics.length}, people=${results.people.length}, quotes=${results.quotes.length})`);
|
|
68
|
+
|
|
69
|
+
const output: Record<string, unknown[]> = {};
|
|
70
|
+
if (results.facts.length > 0) output.facts = results.facts.map(f => ({ id: f.id, name: f.name, description: f.description }));
|
|
71
|
+
if (results.topics.length > 0) output.topics = results.topics.map(t => ({ id: t.id, name: t.name, description: t.description }));
|
|
72
|
+
if (results.people.length > 0) output.people = results.people.map(p => ({ id: p.id, name: p.name, relationship: p.relationship, description: p.description, identifiers: p.identifiers ?? [] }));
|
|
73
|
+
|
|
74
|
+
if (results.quotes.length > 0) {
|
|
75
|
+
const human = getHuman ? getHuman() : null;
|
|
76
|
+
output.quotes = results.quotes.map(q => {
|
|
77
|
+
const linked_items: Array<{ id: string; name: string; type: string }> = [];
|
|
78
|
+
if (human && q.data_item_ids.length > 0) {
|
|
79
|
+
for (const itemId of q.data_item_ids) {
|
|
80
|
+
const fact = human.facts.find(f => f.id === itemId);
|
|
81
|
+
if (fact) { linked_items.push({ id: fact.id, name: fact.name, type: "fact" }); continue; }
|
|
82
|
+
const topic = human.topics.find(t => t.id === itemId);
|
|
83
|
+
if (topic) { linked_items.push({ id: topic.id, name: topic.name, type: "topic" }); continue; }
|
|
84
|
+
const person = human.people.find(p => p.id === itemId);
|
|
85
|
+
if (person) { linked_items.push({ id: person.id, name: person.name, type: "person" }); }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { id: q.id, text: q.text, speaker: q.speaker, linked_items };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Object.keys(output).length === 0) {
|
|
93
|
+
return JSON.stringify({ result: "No relevant memories found for this query." });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return JSON.stringify(output);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
package/src/core/tools/index.ts
CHANGED
|
@@ -18,6 +18,77 @@ import { webFetchExecutor } from "./builtin/web-fetch.js";
|
|
|
18
18
|
/** Hard upper limit on total tool calls per interaction, regardless of individual limits. */
|
|
19
19
|
export const HARD_TOOL_CALL_LIMIT = 10;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* System tools — injected unconditionally into every LLM call that uses tools.
|
|
23
|
+
* NOT stored in state.json. NOT user-configurable. Do NOT count against HARD_TOOL_CALL_LIMIT.
|
|
24
|
+
* Enforce their own per-tool limits via max_calls_per_interaction.
|
|
25
|
+
*/
|
|
26
|
+
export const SYSTEM_TOOLS: ToolDefinition[] = [
|
|
27
|
+
{
|
|
28
|
+
id: "builtin-find-memory",
|
|
29
|
+
provider_id: "ei",
|
|
30
|
+
name: "find_memory",
|
|
31
|
+
display_name: "Find Memory",
|
|
32
|
+
description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
|
|
33
|
+
input_schema: {
|
|
34
|
+
type: "object",
|
|
35
|
+
properties: {
|
|
36
|
+
query: { type: "string", description: "What to search for" },
|
|
37
|
+
types: { type: "array", items: { type: "string", enum: ["facts", "topics", "people", "quotes"] }, description: "Filter to specific types" },
|
|
38
|
+
limit: { type: "number", description: "Max results (1-20, default 10)" },
|
|
39
|
+
recent: { type: "boolean", description: "Sort by most recently mentioned instead of relevance" },
|
|
40
|
+
persona: { type: "string", description: "Filter to what a specific persona has learned. Use their display name." },
|
|
41
|
+
},
|
|
42
|
+
required: ["query"],
|
|
43
|
+
},
|
|
44
|
+
runtime: "any",
|
|
45
|
+
builtin: true,
|
|
46
|
+
enabled: true,
|
|
47
|
+
created_at: new Date(0).toISOString(),
|
|
48
|
+
max_calls_per_interaction: 3,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "builtin-fetch-memory",
|
|
52
|
+
provider_id: "ei",
|
|
53
|
+
name: "fetch_memory",
|
|
54
|
+
display_name: "Fetch Memory",
|
|
55
|
+
description: "Retrieve the full record for a specific memory by its ID. Use when find_memory returns an item and you need its complete details, or when a system prompt references a memory ID. Returns the full Fact, Topic, Person, or Quote record.",
|
|
56
|
+
input_schema: {
|
|
57
|
+
type: "object",
|
|
58
|
+
properties: {
|
|
59
|
+
id: { type: "string", description: "The ID of the memory to retrieve" },
|
|
60
|
+
},
|
|
61
|
+
required: ["id"],
|
|
62
|
+
},
|
|
63
|
+
runtime: "any",
|
|
64
|
+
builtin: true,
|
|
65
|
+
enabled: true,
|
|
66
|
+
created_at: new Date(0).toISOString(),
|
|
67
|
+
max_calls_per_interaction: 3,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: "builtin-fetch-message",
|
|
71
|
+
provider_id: "ei",
|
|
72
|
+
name: "fetch_message",
|
|
73
|
+
display_name: "Fetch Message",
|
|
74
|
+
description: "Retrieve a specific message by its ID, with optional surrounding context. Use when find_memory returns a quote with a message_id and you want to read the original conversation, or when a temporal anchor references a message ID. The 'before' and 'after' parameters return that many additional messages for context (default 0).",
|
|
75
|
+
input_schema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
id: { type: "string", description: "The message ID to retrieve" },
|
|
79
|
+
before: { type: "number", description: "Number of preceding messages to include for context (default 0)" },
|
|
80
|
+
after: { type: "number", description: "Number of following messages to include for context (default 0)" },
|
|
81
|
+
},
|
|
82
|
+
required: ["id"],
|
|
83
|
+
},
|
|
84
|
+
runtime: "any",
|
|
85
|
+
builtin: true,
|
|
86
|
+
enabled: true,
|
|
87
|
+
created_at: new Date(0).toISOString(),
|
|
88
|
+
max_calls_per_interaction: 5,
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
21
92
|
/** Default max calls per tool if not set on the ToolDefinition. */
|
|
22
93
|
const DEFAULT_MAX_CALLS = 3;
|
|
23
94
|
|
|
@@ -32,7 +103,7 @@ export function registerExecutor(executor: ToolExecutor): void {
|
|
|
32
103
|
executorRegistry.set(executor.name, executor);
|
|
33
104
|
}
|
|
34
105
|
|
|
35
|
-
// Register builtins.
|
|
106
|
+
// Register builtins. find_memory is registered lazily via registerFindMemoryExecutor()
|
|
36
107
|
// because it requires Processor.searchHumanData injection.
|
|
37
108
|
registerExecutor(tavilyWebSearchExecutor);
|
|
38
109
|
registerExecutor(tavilyNewsSearchExecutor);
|
|
@@ -42,10 +113,18 @@ registerExecutor(webFetchExecutor);
|
|
|
42
113
|
// file_read and list_directory are registered lazily via registerFileReadExecutor() — Node/TUI only.
|
|
43
114
|
|
|
44
115
|
/**
|
|
45
|
-
* Register the
|
|
116
|
+
* Register the find_memory executor — called by Processor after it's initialized,
|
|
46
117
|
* injecting its own searchHumanData method to avoid circular imports.
|
|
47
118
|
*/
|
|
48
|
-
export function
|
|
119
|
+
export function registerFindMemoryExecutor(executor: ToolExecutor): void {
|
|
120
|
+
executorRegistry.set(executor.name, executor);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function registerFetchMemoryExecutor(executor: ToolExecutor): void {
|
|
124
|
+
executorRegistry.set(executor.name, executor);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function registerFetchMessageExecutor(executor: ToolExecutor): void {
|
|
49
128
|
executorRegistry.set(executor.name, executor);
|
|
50
129
|
}
|
|
51
130
|
|
|
@@ -122,7 +201,9 @@ export async function executeToolCalls(
|
|
|
122
201
|
const toolsByName = new Map(tools.map(t => [t.name, t]));
|
|
123
202
|
|
|
124
203
|
for (const call of calls) {
|
|
125
|
-
|
|
204
|
+
const isSystemTool = SYSTEM_TOOLS.some(t => t.name === call.name);
|
|
205
|
+
|
|
206
|
+
if (!isSystemTool && totalCalls.count >= HARD_TOOL_CALL_LIMIT) {
|
|
126
207
|
console.log(`[Tools] Hard limit (${HARD_TOOL_CALL_LIMIT}) reached — skipping remaining tool calls`);
|
|
127
208
|
break;
|
|
128
209
|
}
|
|
@@ -168,7 +249,9 @@ export async function executeToolCalls(
|
|
|
168
249
|
}
|
|
169
250
|
|
|
170
251
|
callCounts.set(call.name, currentCount + 1);
|
|
171
|
-
|
|
252
|
+
if (!isSystemTool) {
|
|
253
|
+
totalCalls.count++;
|
|
254
|
+
}
|
|
172
255
|
|
|
173
256
|
const newCount = currentCount + 1;
|
|
174
257
|
if (newCount >= maxCalls) {
|
|
@@ -3,6 +3,7 @@ import type { Message } from "../../core/types.js";
|
|
|
3
3
|
import {
|
|
4
4
|
queueTopicScan,
|
|
5
5
|
queuePersonScan,
|
|
6
|
+
queueFactFind,
|
|
6
7
|
type ExtractionContext,
|
|
7
8
|
} from "../../core/orchestrators/human-extraction.js";
|
|
8
9
|
|
|
@@ -101,11 +102,12 @@ export async function importPersonaHistory(
|
|
|
101
102
|
};
|
|
102
103
|
|
|
103
104
|
const extractionModel = settings?.extraction_model;
|
|
105
|
+
queueFactFind(context, stateManager, { extraction_model: extractionModel });
|
|
104
106
|
queueTopicScan(context, stateManager, { extraction_model: extractionModel });
|
|
105
107
|
queuePersonScan(context, stateManager, { extraction_model: extractionModel });
|
|
106
108
|
|
|
107
109
|
result.personasProcessed++;
|
|
108
|
-
result.scansQueued +=
|
|
110
|
+
result.scansQueued += 3;
|
|
109
111
|
}
|
|
110
112
|
|
|
111
113
|
for (const room of Object.values((stateManager.getStorageState() as any).rooms ?? {})) {
|
|
@@ -20,7 +20,7 @@ export function buildDedupPrompt(data: DedupPromptData): { system: string; user:
|
|
|
20
20
|
You are working with Opus 4.6 constraints. These rules prevent overthinking and ensure decisive action:
|
|
21
21
|
|
|
22
22
|
### 1. TOOL BUDGET
|
|
23
|
-
- You have **6 \`
|
|
23
|
+
- You have **6 \`find_memory\` calls** for this cluster
|
|
24
24
|
- Prioritize: verify ambiguous relationships > check parent concepts > validate new entities
|
|
25
25
|
- After 6 calls, make decisions with available information
|
|
26
26
|
- Do NOT waste calls re-checking pairs you already examined
|
|
@@ -55,7 +55,7 @@ You are acting as the curator for a user's internal database. You have been give
|
|
|
55
55
|
|
|
56
56
|
Your secondary directive is to ORGANIZE IT into small, non-repetitive components. The user NEEDS the data, but the data is used by AI agents, so duplication limits usefulness—agents waste tokens re-reading the same information under different names.
|
|
57
57
|
|
|
58
|
-
You have access to a tool called \`
|
|
58
|
+
You have access to a tool called \`find_memory\` (6 calls max — see HARD RULES above). Use it strategically to verify relationships, check for related records, or gather context before making merge decisions.
|
|
59
59
|
|
|
60
60
|
### Decision Process:
|
|
61
61
|
1. **Identify true duplicates**: Examine each record. Are these genuinely the same thing with different wording (85%+ core meaning overlap), or are they distinct but related concepts?
|
|
@@ -94,7 +94,7 @@ ${buildRecordFormatExamples(data.itemType)}
|
|
|
94
94
|
- Every removed record MUST have "replaced_by" pointing to the canonical record that absorbed its data.
|
|
95
95
|
- The "update" array should contain AT LEAST ONE record (the canonical/merged one), even if all others are removed.
|
|
96
96
|
- If records are NOT duplicates (just similar), return them ALL in "update" unchanged, with empty "remove" and "add" arrays.
|
|
97
|
-
- Use \`
|
|
97
|
+
- Use \`find_memory\` strategically (6 calls max) to check for related records or gather context before making irreversible merge decisions.`;
|
|
98
98
|
|
|
99
99
|
const payload = JSON.stringify({
|
|
100
100
|
cluster: data.cluster.map(stripEmbedding),
|
|
@@ -15,7 +15,7 @@ export interface RewriteScanPromptData {
|
|
|
15
15
|
/** Phase 1 output: array of subject strings (parsed from LLM JSON response). */
|
|
16
16
|
export type RewriteScanResult = string[];
|
|
17
17
|
|
|
18
|
-
/** A single subject and the
|
|
18
|
+
/** A single subject and the find_memory matches found for it. */
|
|
19
19
|
export interface RewriteSubjectMatch {
|
|
20
20
|
searchTerm: string;
|
|
21
21
|
matches: DataItemBase[]; // Top 3 from searchHumanData, may be empty
|
|
@@ -93,6 +93,19 @@ If you are unsure of the type, use \`Nickname\` as a fallback. Do NOT invent typ
|
|
|
93
93
|
|
|
94
94
|
Only include \`identifiers\` when explicitly mentioned in the conversation — omit it entirely if nothing qualifies.
|
|
95
95
|
|
|
96
|
+
## Confidence & Relationship Type
|
|
97
|
+
|
|
98
|
+
For each person, rate how important they are to the human user's life:
|
|
99
|
+
|
|
100
|
+
- \`confidence\`: integer 1–5
|
|
101
|
+
- 1–2 = mentioned in passing, single event, no ongoing relevance
|
|
102
|
+
- 3 = unclear significance — may matter, may not
|
|
103
|
+
- 4–5 = clearly important, recurring presence, meaningful relationship
|
|
104
|
+
- \`relationship_type\`: one of \`"family"\` | \`"friend"\` | \`"colleague"\` | \`"acquaintance"\` | \`"transactional"\` | \`"unknown"\`
|
|
105
|
+
- Use \`"transactional"\` when the person appeared only in the context of a single transaction (purchase, sale, support ticket, delivery)
|
|
106
|
+
|
|
107
|
+
Use the full range. Most extractions should score 1–3. A confidence of 4–5 means this person genuinely matters to the user's life.
|
|
108
|
+
|
|
96
109
|
## Output Format
|
|
97
110
|
|
|
98
111
|
\`\`\`json
|
|
@@ -105,6 +118,8 @@ Only include \`identifiers\` when explicitly mentioned in the conversation — o
|
|
|
105
118
|
],
|
|
106
119
|
"description": "1-2 sentences: who this person is and their role in the user's life",
|
|
107
120
|
"relationship": "Father|Mother|Brother|Son|Friend|Coworker|Self|etc.",
|
|
121
|
+
"relationship_type": "family|friend|colleague|acquaintance|transactional|unknown",
|
|
122
|
+
"confidence": 4,
|
|
108
123
|
"reason": "Evidence from the conversation that justified flagging this person"
|
|
109
124
|
}
|
|
110
125
|
]
|
|
@@ -143,6 +158,8 @@ Scan the "Most Recent Messages" for PEOPLE in the human user's life.
|
|
|
143
158
|
"identifiers": [{ "type": "GitHub", "value": "handle" }],
|
|
144
159
|
"description": "1-2 sentences: who this person is and their role in the user's life",
|
|
145
160
|
"relationship": "Father|Mother|Brother|Son|Friend|Coworker|Self|etc.",
|
|
161
|
+
"relationship_type": "family|friend|colleague|acquaintance|transactional|unknown",
|
|
162
|
+
"confidence": 4,
|
|
146
163
|
"reason": "Evidence from the conversation that justified flagging this person"
|
|
147
164
|
}
|
|
148
165
|
]
|
|
@@ -55,11 +55,15 @@ export interface TopicScanCandidate {
|
|
|
55
55
|
reason: string;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
export type PersonRelationshipType = "family" | "friend" | "colleague" | "acquaintance" | "transactional" | "unknown";
|
|
59
|
+
|
|
58
60
|
export interface PersonScanCandidate {
|
|
59
61
|
name: string;
|
|
60
62
|
identifiers?: Array<{ type: string; value: string; is_primary?: boolean }>;
|
|
61
63
|
description: string;
|
|
62
64
|
relationship: string;
|
|
65
|
+
relationship_type?: PersonRelationshipType;
|
|
66
|
+
confidence?: number;
|
|
63
67
|
reason: string;
|
|
64
68
|
}
|
|
65
69
|
|
|
@@ -464,21 +464,28 @@ export function buildTemporalAnchorsSection(anchors: TemporalAnchor[], humanName
|
|
|
464
464
|
|
|
465
465
|
const formatted = anchors.map(a => {
|
|
466
466
|
const speaker = a.role === "human" ? humanName : "You";
|
|
467
|
-
let
|
|
468
|
-
if (a._synthesis
|
|
469
|
-
|
|
467
|
+
let preview: string;
|
|
468
|
+
if (a._synthesis) {
|
|
469
|
+
const raw = a.content ?? "";
|
|
470
|
+
const firstSentenceEnd = raw.search(/\.\s/);
|
|
471
|
+
const snippet = firstSentenceEnd > 0 && firstSentenceEnd <= 120
|
|
472
|
+
? raw.slice(0, firstSentenceEnd + 1)
|
|
473
|
+
: raw.slice(0, 100);
|
|
474
|
+
preview = `[${humanName} generated an image: "${snippet}…"]`;
|
|
470
475
|
} else if (a.silence_reason) {
|
|
471
476
|
const silentParty = a.role === "human" ? humanName : "You";
|
|
472
|
-
|
|
477
|
+
const truncated = a.silence_reason.length > 80 ? `${a.silence_reason.slice(0, 80)}…` : a.silence_reason;
|
|
478
|
+
preview = `${silentParty} chose not to respond: "${truncated}"`;
|
|
473
479
|
} else {
|
|
474
|
-
|
|
480
|
+
const raw = a.content ?? "";
|
|
481
|
+
preview = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
475
482
|
}
|
|
476
|
-
return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${
|
|
483
|
+
return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${preview}\n → fetch_message("${a.id}") for full content`;
|
|
477
484
|
}).join("\n\n");
|
|
478
485
|
|
|
479
486
|
return `## Temporal Anchors
|
|
480
487
|
|
|
481
|
-
|
|
488
|
+
Pinned moments from your shared history. These are snapshots — use fetch_message(id) if one feels relevant to pull the full memory:
|
|
482
489
|
|
|
483
490
|
${formatted}`;
|
|
484
491
|
}
|
package/tui/README.md
CHANGED
|
@@ -168,9 +168,10 @@ Rooms have three modes, set at creation time:
|
|
|
168
168
|
| `XDG_DATA_HOME` | `~/.local/share` | XDG base directory. Ignored if `EI_DATA_PATH` is set. |
|
|
169
169
|
| `EI_SYNC_USERNAME` | — | Username for remote sync. If set at startup, bootstraps sync credentials automatically (useful for dotfiles/scripts). |
|
|
170
170
|
| `EI_SYNC_PASSPHRASE` | — | Passphrase for remote sync. Paired with `EI_SYNC_USERNAME`. |
|
|
171
|
-
| `
|
|
171
|
+
| `EI_LOG_LEVEL` | `warn` | Log verbosity written to `tui.log`: `error`, `warn`, `info`, `debug`. |
|
|
172
|
+
| `EI_DEBUG_NETWORK_VERBOSE` | — | Set to `1` to dump full LLM request/response payloads as JSON files under `$EI_DATA_PATH/logs/`. One file per call, named `TIMESTAMP_callN_STEP.json`. |
|
|
172
173
|
|
|
173
|
-
> **Tip**: `tail -f $EI_DATA_PATH/tui.log` to watch live
|
|
174
|
+
> **Tip**: `tail -f $EI_DATA_PATH/tui.log` to watch live TUI output. Set `EI_LOG_LEVEL=info` to see LLM call summaries (model, latency, token counts). Set `EI_DEBUG_NETWORK_VERBOSE=1` to dump full request/response payloads to `$EI_DATA_PATH/logs/`.
|
|
174
175
|
|
|
175
176
|
|
|
176
177
|
# Development
|
package/tui/src/util/logger.ts
CHANGED
|
@@ -22,7 +22,7 @@ const LOG_LEVELS: Record<LogLevel, number> = {
|
|
|
22
22
|
error: 3,
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
const currentLevel: LogLevel = (Bun.env.EI_LOG_LEVEL as LogLevel) || "
|
|
25
|
+
const currentLevel: LogLevel = (Bun.env.EI_LOG_LEVEL as LogLevel) || "warn";
|
|
26
26
|
|
|
27
27
|
function shouldLog(level: LogLevel): boolean {
|
|
28
28
|
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|