ei-tui 0.6.0 → 0.6.1

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.6.0",
3
+ "version": "0.6.1",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -123,10 +123,6 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
123
123
  const roomId = response.request.data.roomId as string | undefined;
124
124
  const candidateCategory = response.request.data.candidateCategory as string | undefined;
125
125
 
126
- if (!result.name || !result.description || result.sentiment === undefined) {
127
- throw new Error(`[handleTopicUpdate] Missing required fields: name=${result.name}, description=${!!result.description}, sentiment=${result.sentiment}`);
128
- }
129
-
130
126
  const personaIds = personaId.split("|").filter(Boolean);
131
127
  const primaryId = personaIds[0] ?? personaId;
132
128
 
@@ -147,14 +143,21 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
147
143
 
148
144
  const existingTopic = isNewItem ? undefined : human.topics.find(t => t.id === existingItemId);
149
145
 
146
+ const resolvedName = result.name || existingTopic?.name;
147
+ const resolvedDescription = typeof result.description === 'string' ? result.description : existingTopic?.description;
148
+
149
+ if (!resolvedName || !resolvedDescription || result.sentiment === undefined) {
150
+ throw new Error(`[handleTopicUpdate] Missing required fields: name=${resolvedName}, description=${!!resolvedDescription}, sentiment=${result.sentiment}`);
151
+ }
152
+
150
153
  let embedding: number[] | undefined;
151
154
  try {
152
155
  const embeddingService = getEmbeddingService();
153
156
  const category = result.category ?? candidateCategory ?? existingTopic?.category;
154
- const text = getTopicEmbeddingText({ name: result.name, category, description: result.description });
157
+ const text = getTopicEmbeddingText({ name: resolvedName, category, description: resolvedDescription });
155
158
  embedding = await embeddingService.embed(text);
156
159
  } catch (err) {
157
- console.warn(`[handleTopicUpdate] Failed to compute embedding for topic "${result.name}":`, err);
160
+ console.warn(`[handleTopicUpdate] Failed to compute embedding for topic "${resolvedName}":`, err);
158
161
  }
159
162
 
160
163
  const exposureImpact = result.exposure_impact as ExposureImpact | undefined;
@@ -167,8 +170,8 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
167
170
 
168
171
  const topic: Topic = {
169
172
  id: itemId,
170
- name: result.name,
171
- description: result.description,
173
+ name: resolvedName,
174
+ description: resolvedDescription,
172
175
  sentiment: result.sentiment,
173
176
  category: result.category ?? candidateCategory ?? existingTopic?.category,
174
177
  exposure_current: calculateExposureCurrent(exposureImpact, existingTopic?.exposure_current ?? 0),
@@ -189,7 +192,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
189
192
  : state.messages_get(personaId);
190
193
  await validateAndStoreQuotes(result.quotes, allMessages, itemId, personaDisplayName, personaGroup, state);
191
194
 
192
- console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${result.name}"`);
195
+ console.log(`[handleTopicUpdate] ${isNewItem ? "Created" : "Updated"} topic "${resolvedName}"`);
193
196
  }
194
197
 
195
198
  export async function handlePersonUpdate(response: LLMResponse, state: StateManager): Promise<void> {
@@ -250,7 +250,7 @@ ONLY ANALYZE the "Most Recent Messages". The "Earlier Conversation" is provided
250
250
  ${jsonTemplate}
251
251
  \`\`\`
252
252
 
253
- When returning a record, **ALWAYS** include \`name\`, \`description\`, and \`sentiment\`.
253
+ When returning a record, always include \`sentiment\`. Include \`name\` only if you are changing it; omit it to keep the existing name. Always include \`description\` when returning a record.
254
254
 
255
255
  If you find **NO EVIDENCE** of this TOPIC in the "Most Recent Messages", respond with: \`{}\`
256
256
 
@@ -80,21 +80,27 @@ export const meCommand: Command = {
80
80
  }
81
81
 
82
82
  for (const fact of parsed.facts) {
83
- await ctx.ei.upsertFact(fact);
83
+ if (parsed.changedFactIds.has(fact.id)) {
84
+ await ctx.ei.upsertFact(fact);
85
+ }
84
86
  }
85
87
  for (const topic of parsed.topics) {
86
- await ctx.ei.upsertTopic(topic);
88
+ if (parsed.changedTopicIds.has(topic.id)) {
89
+ await ctx.ei.upsertTopic(topic);
90
+ }
87
91
  }
88
92
  for (const person of parsed.people) {
89
- await ctx.ei.upsertPerson(person);
93
+ if (parsed.changedPersonIds.has(person.id)) {
94
+ await ctx.ei.upsertPerson(person);
95
+ }
90
96
  }
91
97
 
92
98
  const deleteCount = parsed.deletedFactIds.length +
93
99
  parsed.deletedTopicIds.length +
94
100
  parsed.deletedPersonIds.length;
95
- const updateCount = parsed.facts.length +
96
- parsed.topics.length +
97
- parsed.people.length;
101
+ const updateCount = parsed.changedFactIds.size +
102
+ parsed.changedTopicIds.size +
103
+ parsed.changedPersonIds.size;
98
104
 
99
105
  ctx.showNotification(`Updated ${updateCount} items, deleted ${deleteCount}`, "info");
100
106
  return;
@@ -0,0 +1,66 @@
1
+ import YAML from "yaml";
2
+ import type { Message } from "../../../src/core/types.js";
3
+ import { ContextStatus } from "../../../src/core/types.js";
4
+
5
+ interface EditableMessage {
6
+ id: string;
7
+ role: "human" | "system";
8
+ timestamp: string;
9
+ context_status: ContextStatus;
10
+ _delete?: boolean;
11
+ content?: string;
12
+ silence_reason?: string;
13
+ }
14
+
15
+ function getContent(m: { content?: string; verbal_response?: string; action_response?: string }): string {
16
+ if (m.content) return m.content;
17
+ const parts: string[] = [];
18
+ if (m.action_response) parts.push(`_${m.action_response}_`);
19
+ if (m.verbal_response) parts.push(m.verbal_response);
20
+ return parts.join('\n\n');
21
+ }
22
+
23
+ export function contextToYAML(messages: Message[]): string {
24
+ const header = [
25
+ "# context_status: default | always | never",
26
+ "# _delete: true — permanently removes the message",
27
+ "# content | silence_reason",
28
+ ].join("\n");
29
+
30
+ const data: EditableMessage[] = messages.map((m) => ({
31
+ id: m.id,
32
+ role: m.role,
33
+ timestamp: m.timestamp,
34
+ context_status: m.context_status,
35
+ _delete: false,
36
+ content: getContent(m) || undefined,
37
+ silence_reason: m.silence_reason,
38
+ }));
39
+
40
+ return header + "\n" + YAML.stringify(data, { lineWidth: 0 });
41
+ }
42
+
43
+ export interface ContextYAMLResult {
44
+ messages: Array<{ id: string; context_status: ContextStatus }>;
45
+ deletedMessageIds: string[];
46
+ }
47
+
48
+ export function contextFromYAML(yamlContent: string): ContextYAMLResult {
49
+ const data = YAML.parse(yamlContent) as EditableMessage[];
50
+
51
+ const deletedMessageIds: string[] = [];
52
+ const messages: Array<{ id: string; context_status: ContextStatus }> = [];
53
+
54
+ for (const m of data ?? []) {
55
+ if (m._delete) {
56
+ deletedMessageIds.push(m.id);
57
+ } else {
58
+ const normalized = (m.context_status ?? 'default').toString().toLowerCase() as ContextStatus;
59
+ messages.push({ id: m.id, context_status: normalized });
60
+ }
61
+ }
62
+
63
+ return { messages, deletedMessageIds };
64
+ }
65
+
66
+
@@ -0,0 +1,274 @@
1
+ import YAML from "yaml";
2
+ import type {
3
+ HumanEntity,
4
+ Fact,
5
+ Topic,
6
+ Person,
7
+ PersonIdentifier,
8
+ } from "../../../src/core/types.js";
9
+ import { BUILT_IN_FACT_NAMES } from "../../../src/core/constants/built-in-facts.js";
10
+ import { BUILT_IN_IDENTIFIER_TYPES } from "../../../src/core/constants/built-in-identifier-types.js";
11
+
12
+ interface EditableTopic extends Omit<Topic, 'persona_groups'> {
13
+ _delete?: boolean;
14
+ persona_groups?: Record<string, boolean>[];
15
+ }
16
+
17
+ interface EditableFact extends Omit<Fact, 'persona_groups'> {
18
+ _delete?: boolean;
19
+ persona_groups?: Record<string, boolean>[];
20
+ }
21
+
22
+ interface YAMLPersonIdentifier {
23
+ type: string;
24
+ value: string;
25
+ primary?: true;
26
+ }
27
+
28
+ interface EditablePersonYAML extends Omit<Person, 'identifiers' | 'persona_groups'> {
29
+ identifiers: YAMLPersonIdentifier[];
30
+ _delete?: boolean;
31
+ persona_groups?: Record<string, boolean>[];
32
+ }
33
+
34
+ interface EditableHumanData {
35
+ facts: EditableFact[];
36
+ topics: EditableTopic[];
37
+ people: EditablePersonYAML[];
38
+ }
39
+
40
+ type WithReadOnlyFields = {
41
+ learned_on?: string;
42
+ learned_by?: string;
43
+ validated_date?: string;
44
+ last_mentioned?: string;
45
+ last_updated: string;
46
+ last_changed_by?: string;
47
+ };
48
+
49
+ function readOnlyToEnd<T extends WithReadOnlyFields>(item: T): T {
50
+ const { learned_on, learned_by, validated_date, last_mentioned, last_updated, last_changed_by, ...rest } = item;
51
+ return { ...rest, learned_on, learned_by, validated_date, last_mentioned, last_updated, last_changed_by } as T;
52
+ }
53
+
54
+ function buildGroupCheckboxMap(itemGroups: string[], allGroups: string[]): Record<string, boolean>[] {
55
+ const activeSet = new Set(itemGroups);
56
+ return [...new Set([...allGroups, ...itemGroups])].map(g => ({ [g]: activeSet.has(g) }));
57
+ }
58
+
59
+ function toYAMLIdentifiers(identifiers: PersonIdentifier[], personaLookup?: Map<string, string>): YAMLPersonIdentifier[] {
60
+ return identifiers.map(({ type, value, is_primary }) => {
61
+ const resolvedValue = type === 'Ei Persona' ? (personaLookup?.get(value) ?? value) : value;
62
+ const entry: YAMLPersonIdentifier = { type, value: resolvedValue };
63
+ if (is_primary) entry.primary = true;
64
+ return entry;
65
+ });
66
+ }
67
+
68
+ function knownTypesComment(personaLookup?: Map<string, string>): string {
69
+ const lines = [`# Valid types: ${BUILT_IN_IDENTIFIER_TYPES.join(', ')}`];
70
+ if (personaLookup && personaLookup.size > 0) {
71
+ lines.push(`# Personas: ${Array.from(personaLookup.values()).join(', ')}`);
72
+ }
73
+ return lines.join('\n');
74
+ }
75
+
76
+ function parseGroupCheckboxMap(groups: Record<string, boolean>[] | undefined): string[] {
77
+ if (!groups) return [];
78
+ const result: string[] = [];
79
+ for (const record of groups) {
80
+ for (const [name, active] of Object.entries(record)) {
81
+ if (active) result.push(name);
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+
87
+ export function humanToYAML(human: HumanEntity, personaLookup?: Map<string, string>, allGroups: string[] = []): string {
88
+ const data: EditableHumanData = {
89
+ facts: human.facts.map(f => { const { interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(f); return { ...rest, persona_groups: buildGroupCheckboxMap(persona_groups ?? [], allGroups), _delete: false }; }),
90
+ topics: human.topics.map(t => { const { interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(t); return { ...rest, persona_groups: buildGroupCheckboxMap(persona_groups ?? [], allGroups), _delete: false }; }),
91
+ people: human.people.map(p => {
92
+ const { identifiers, interested_personas: _ip, persona_groups, ...rest } = readOnlyToEnd(p);
93
+ return {
94
+ ...rest,
95
+ persona_groups: buildGroupCheckboxMap(persona_groups ?? [], allGroups),
96
+ identifiers: toYAMLIdentifiers(identifiers ?? [], personaLookup),
97
+ _delete: false as const,
98
+ };
99
+ }),
100
+ };
101
+
102
+ const personComment = knownTypesComment(personaLookup);
103
+
104
+ return YAML.stringify(data, {
105
+ lineWidth: 0,
106
+ })
107
+ .replace(/^(\s+)(learned_on: .+)$/mg, '$1# [read-only] $2')
108
+ .replace(/^(\s+)(learned_by: )(.+)$/mg, (_, indent, key, val) => {
109
+ const trimmed = val.trim();
110
+ const displayName = personaLookup?.get(trimmed) ?? trimmed;
111
+ return `${indent}# [read-only] ${key}${displayName}`;
112
+ })
113
+ .replace(/^(\s+)(validated_date: .+)$/mg, '$1# [read-only] $2')
114
+ .replace(/^(\s+)(last_mentioned: .+)$/mg, '$1# [read-only] $2')
115
+ .replace(/^(\s+)(last_updated: .+)$/mg, '$1# [read-only] $2')
116
+ .replace(/^(\s+)(last_changed_by: )(.+)$/mg, (_, indent, key, val) => {
117
+ const trimmed = val.trim();
118
+ const displayName = personaLookup?.get(trimmed) ?? trimmed;
119
+ return `${indent}# [read-only] ${key}${displayName}`;
120
+ })
121
+ .replace(/^(\s+)(identifiers:)/mg, (_, indent, _key) => {
122
+ return `${indent}${personComment}\n${indent}identifiers:`;
123
+ });
124
+ }
125
+
126
+ export interface HumanYAMLResult {
127
+ facts: Fact[];
128
+ topics: Topic[];
129
+ people: Person[];
130
+ deletedFactIds: string[];
131
+ deletedTopicIds: string[];
132
+ deletedPersonIds: string[];
133
+ changedFactIds: Set<string>;
134
+ changedTopicIds: Set<string>;
135
+ changedPersonIds: Set<string>;
136
+ }
137
+
138
+ function identifiersEqual(a: PersonIdentifier[] | undefined, b: PersonIdentifier[] | undefined): boolean {
139
+ const normalize = (ids: PersonIdentifier[] | undefined) =>
140
+ [...(ids ?? [])].sort((x, y) => `${x.type}:${x.value}`.localeCompare(`${y.type}:${y.value}`));
141
+ const na = normalize(a);
142
+ const nb = normalize(b);
143
+ if (na.length !== nb.length) return false;
144
+ return na.every((id, i) =>
145
+ id.type === nb[i].type &&
146
+ id.value === nb[i].value &&
147
+ Boolean(id.is_primary) === Boolean(nb[i].is_primary)
148
+ );
149
+ }
150
+
151
+ function groupsEqual(a: string[] | undefined, b: string[] | undefined): boolean {
152
+ const sa = [...(a ?? [])].sort();
153
+ const sb = [...(b ?? [])].sort();
154
+ if (sa.length !== sb.length) return false;
155
+ return sa.every((v, i) => v === sb[i]);
156
+ }
157
+
158
+ function factChanged(parsed: Fact, original: Fact): boolean {
159
+ const scalarFields: (keyof Fact)[] = [
160
+ 'name', 'description', 'sentiment',
161
+ ];
162
+ for (const field of scalarFields) {
163
+ if (parsed[field] !== original[field]) return true;
164
+ }
165
+ return !groupsEqual(parsed.persona_groups, original.persona_groups);
166
+ }
167
+
168
+ function topicChanged(parsed: Topic, original: Topic): boolean {
169
+ const scalarFields: (keyof Topic)[] = [
170
+ 'name', 'description', 'sentiment', 'exposure_current', 'exposure_desired', 'category',
171
+ ];
172
+ for (const field of scalarFields) {
173
+ if (parsed[field] !== original[field]) return true;
174
+ }
175
+ return !groupsEqual(parsed.persona_groups, original.persona_groups);
176
+ }
177
+
178
+ function personChanged(parsed: Person, original: Person): boolean {
179
+ const scalarFields: (keyof Person)[] = [
180
+ 'name', 'description', 'sentiment', 'relationship',
181
+ 'exposure_current', 'exposure_desired',
182
+ ];
183
+ for (const field of scalarFields) {
184
+ if (parsed[field] !== original[field]) return true;
185
+ }
186
+ if (!groupsEqual(parsed.persona_groups, original.persona_groups)) return true;
187
+ return !identifiersEqual(parsed.identifiers, original.identifiers);
188
+ }
189
+
190
+ export function humanFromYAML(yamlContent: string, original?: HumanEntity): HumanYAMLResult {
191
+ const stripped = yamlContent
192
+ .split('\n')
193
+ .filter(line => !/^\s*#\s*\[read-only\]/.test(line))
194
+ .join('\n');
195
+ const data = YAML.parse(stripped) as EditableHumanData;
196
+
197
+ const deletedFactIds: string[] = [];
198
+ const deletedTopicIds: string[] = [];
199
+ const deletedPersonIds: string[] = [];
200
+ const changedFactIds = new Set<string>();
201
+ const changedTopicIds = new Set<string>();
202
+ const changedPersonIds = new Set<string>();
203
+
204
+ const facts: Fact[] = [];
205
+ for (const f of data.facts ?? []) {
206
+ if (f._delete && !BUILT_IN_FACT_NAMES.has(f.name)) {
207
+ deletedFactIds.push(f.id);
208
+ } else {
209
+ const { _delete, persona_groups: groupMap, ...parsed } = f;
210
+ const originalFact = original?.facts.find(of => of.id === parsed.id);
211
+ const fact: Fact = originalFact
212
+ ? { ...originalFact, ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) }
213
+ : { ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) };
214
+ facts.push(fact);
215
+ if (!originalFact || factChanged(fact, originalFact)) {
216
+ if (fact.description && !originalFact?.validated_date) {
217
+ fact.validated_date = new Date().toISOString();
218
+ }
219
+ changedFactIds.add(fact.id);
220
+ }
221
+ }
222
+ }
223
+
224
+ const topics: Topic[] = [];
225
+ for (const t of data.topics ?? []) {
226
+ if (t._delete) {
227
+ deletedTopicIds.push(t.id);
228
+ } else {
229
+ const { _delete, persona_groups: groupMap, ...parsed } = t;
230
+ const originalTopic = original?.topics.find(ot => ot.id === parsed.id);
231
+ const topic: Topic = originalTopic
232
+ ? { ...originalTopic, ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) }
233
+ : { ...parsed, persona_groups: parseGroupCheckboxMap(groupMap) };
234
+ topics.push(topic);
235
+ if (!originalTopic || topicChanged(topic, originalTopic)) {
236
+ changedTopicIds.add(topic.id);
237
+ }
238
+ }
239
+ }
240
+
241
+ const people: Person[] = [];
242
+ for (const p of data.people ?? []) {
243
+ if (p._delete) {
244
+ deletedPersonIds.push(p.id);
245
+ } else {
246
+ const { _delete, identifiers: yamlIdentifiers, persona_groups: groupMap, ...parsed } = p;
247
+ const identifiers: PersonIdentifier[] = (yamlIdentifiers ?? []).map(({ type, value, primary }) => ({
248
+ type,
249
+ value,
250
+ ...(primary ? { is_primary: true } : {}),
251
+ }));
252
+ const originalPerson = original?.people.find(op => op.id === parsed.id);
253
+ const person: Person = originalPerson
254
+ ? { ...originalPerson, ...parsed, identifiers, persona_groups: parseGroupCheckboxMap(groupMap) }
255
+ : { ...parsed, identifiers, persona_groups: parseGroupCheckboxMap(groupMap) };
256
+ people.push(person);
257
+ if (!originalPerson || personChanged(person, originalPerson)) {
258
+ changedPersonIds.add(person.id);
259
+ }
260
+ }
261
+ }
262
+
263
+ return {
264
+ facts,
265
+ topics,
266
+ people,
267
+ deletedFactIds,
268
+ deletedTopicIds,
269
+ deletedPersonIds,
270
+ changedFactIds,
271
+ changedTopicIds,
272
+ changedPersonIds,
273
+ };
274
+ }