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.
@@ -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.log(`[Tools] tools_getForPersona(${personaId}): persona has no assigned tools`);
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.log(`[Tools] tools_getForPersona: assigned tool id=${id} not found in registry`); continue; }
1081
- if (!tool.enabled) { console.log(`[Tools] tools_getForPersona: tool "${tool.name}" is disabled`); continue; }
1082
- if (!enabledProviderIds.has(tool.provider_id)) { console.log(`[Tools] tools_getForPersona: tool "${tool.name}" provider is disabled`); continue; }
1083
- if (!(tool.runtime === "any" || (tool.runtime === "node" && isTUI))) { console.log(`[Tools] tools_getForPersona: tool "${tool.name}" runtime "${tool.runtime}" not available (isTUI=${isTUI})`); continue; }
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.log(`[Tools] tools_getForPersona(${personaId}): resolved ${result.length}/${assignedIds.size} tools: [${result.map(t => t.name).join(", ")}]`);
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
+ }
@@ -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. read_memory is registered lazily via registerReadMemoryExecutor()
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 read_memory executor — called by Processor after it's initialized,
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 registerReadMemoryExecutor(executor: ToolExecutor): void {
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
- if (totalCalls.count >= HARD_TOOL_CALL_LIMIT) {
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
- totalCalls.count++;
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 += 2;
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 \`read_memory\` calls** for this cluster
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 \`read_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.
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 \`read_memory\` strategically (6 calls max) to check for related records or gather context before making irreversible merge decisions.`;
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 read_memory matches found for it. */
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 text: string;
468
- if (a._synthesis && a.content) {
469
- text = `[${humanName} used your conversation to generate an image. The full prompt was: "${a.content}"]`;
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
- text = `${silentParty} chose not to respond because: ${a.silence_reason}`;
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
- text = a.content ?? "";
480
+ const raw = a.content ?? "";
481
+ preview = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
475
482
  }
476
- return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${text}`;
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
- These are pinned moments from your shared history — preserved across context windows as part of who you are:
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
  }
@@ -8,6 +8,7 @@ import type { ToolDefinition } from "../../core/types.js";
8
8
  import type { PersonaEntity } from "../../core/types/entities.js";
9
9
 
10
10
  export interface TemporalAnchor {
11
+ id: string;
11
12
  role: "human" | "system";
12
13
  content?: string;
13
14
  silence_reason?: string;
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
- | `EDITOR` / `VISUAL` | `vi` | Editor opened by `/details`, `/me`, `/settings`, `/context`, `/quotes`, etc. Falls back to `VISUAL` if `EDITOR` is unset. |
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 debug output.
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
@@ -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) || "debug";
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];