ei-tui 0.8.0 → 0.9.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 +9 -6
- package/package.json +1 -1
- package/src/cli/mcp.ts +2 -2
- package/src/cli.ts +1 -1
- package/src/core/handlers/heartbeat.ts +63 -8
- package/src/core/handlers/index.ts +2 -1
- package/src/core/handlers/persona-response.ts +3 -5
- package/src/core/handlers/rooms.ts +3 -5
- package/src/core/handlers/utils.ts +5 -4
- package/src/core/heartbeat-manager.ts +16 -47
- package/src/core/message-manager.ts +6 -2
- package/src/core/orchestrators/ceremony.ts +49 -4
- package/src/core/orchestrators/human-extraction.ts +2 -2
- package/src/core/persona-manager.ts +1 -2
- package/src/core/personas/opencode-agent.ts +7 -2
- package/src/core/processor.ts +5 -12
- package/src/core/prompt-context-builder.ts +11 -1
- package/src/core/queue-processor.ts +4 -4
- package/src/core/room-manager.ts +6 -6
- package/src/core/state/human.ts +0 -1
- package/src/core/state/personas.ts +22 -13
- package/src/core/state/rooms.ts +0 -2
- package/src/core/state-manager.ts +83 -11
- package/src/core/types/data-items.ts +2 -2
- package/src/core/types/entities.ts +8 -3
- package/src/core/types/enums.ts +1 -0
- package/src/core/types/integrations.ts +1 -1
- package/src/core/types/llm.ts +2 -4
- package/src/core/types/rooms.ts +0 -4
- package/src/integrations/claude-code/importer.ts +1 -5
- package/src/integrations/cursor/importer.ts +1 -5
- package/src/integrations/opencode/importer.ts +1 -4
- package/src/integrations/opencode/types.ts +17 -1
- package/src/prompts/heartbeat/check.ts +7 -18
- package/src/prompts/heartbeat/ei.ts +14 -0
- package/src/prompts/heartbeat/types.ts +7 -5
- package/src/prompts/index.ts +9 -0
- package/src/prompts/message-utils.ts +7 -4
- package/src/prompts/reflection/index.ts +77 -0
- package/src/prompts/reflection/types.ts +26 -0
- package/src/prompts/response/index.ts +5 -2
- package/src/prompts/response/sections.ts +29 -1
- package/src/prompts/response/types.ts +10 -2
- package/src/prompts/room/sections.ts +4 -7
- package/src/prompts/room/types.ts +3 -6
- package/src/storage/embeddings.ts +69 -34
- package/src/storage/merge.ts +1 -1
- package/src/templates/welcome.ts +0 -1
- package/tui/README.md +5 -2
- package/tui/src/commands/editor.tsx +0 -1
- package/tui/src/commands/persona.tsx +89 -3
- package/tui/src/commands/reflect.tsx +375 -0
- package/tui/src/commands/registry.ts +2 -0
- package/tui/src/components/CYPTreeOverlay.tsx +0 -2
- package/tui/src/components/MAPScoreOverlay.tsx +1 -1
- package/tui/src/components/MessageList.tsx +6 -9
- package/tui/src/components/PromptInput.tsx +3 -1
- package/tui/src/components/RoomMessageList.tsx +2 -6
- package/tui/src/components/Sidebar.tsx +3 -5
- package/tui/src/components/StatusBar.tsx +26 -14
- package/tui/src/context/keyboard.tsx +2 -2
- package/tui/src/util/cyp-editor.tsx +2 -6
- package/tui/src/util/yaml-context.ts +2 -6
- package/tui/src/util/yaml-persona.ts +3 -3
- package/tui/src/util/yaml-settings.ts +0 -3
package/src/prompts/index.ts
CHANGED
|
@@ -81,6 +81,15 @@ export {
|
|
|
81
81
|
buildRoomResponsePrompt,
|
|
82
82
|
buildRoomJudgePrompt,
|
|
83
83
|
} from "./room/index.js";
|
|
84
|
+
|
|
85
|
+
export { buildReflectionCriticPrompt } from "./reflection/index.js";
|
|
86
|
+
export type {
|
|
87
|
+
ReflectionCriticPromptData,
|
|
88
|
+
ReflectionCriticResult,
|
|
89
|
+
PersonaIdentitySnapshot,
|
|
90
|
+
} from "./reflection/types.js";
|
|
91
|
+
|
|
92
|
+
|
|
84
93
|
export type {
|
|
85
94
|
RoomResponsePromptData,
|
|
86
95
|
RoomJudgePromptData,
|
|
@@ -8,24 +8,27 @@ export function getMessageDisplayText(message: Message): string | null {
|
|
|
8
8
|
const content = getMessageContent(message);
|
|
9
9
|
if (content) parts.push(content);
|
|
10
10
|
if (message.silence_reason) {
|
|
11
|
-
const name = message.
|
|
11
|
+
const name = message.role === "human"
|
|
12
|
+
? (message.speaker_name ?? 'Human')
|
|
13
|
+
: (message.speaker_name ?? 'Persona');
|
|
12
14
|
parts.push(`[${name} chose not to respond because: ${message.silence_reason}]`);
|
|
13
15
|
}
|
|
14
16
|
if (parts.length === 0) return null;
|
|
15
17
|
return parts.join('\n\n');
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
export function buildChatMessageContent(message: Message): string {
|
|
20
|
+
export function buildChatMessageContent(message: Message, humanName?: string): string {
|
|
19
21
|
const parts: string[] = [];
|
|
20
22
|
const content = getMessageContent(message);
|
|
21
23
|
|
|
22
24
|
if (message._synthesis && content) {
|
|
23
|
-
parts.push(`[The user used your conversation to generate an image. The full prompt was: "${content}"]`);
|
|
25
|
+
parts.push(`[${humanName ?? 'The user'} used your conversation to generate an image. The full prompt was: "${content}"]`);
|
|
24
26
|
} else if (content) {
|
|
25
27
|
parts.push(content);
|
|
26
28
|
}
|
|
27
29
|
if (message.silence_reason) {
|
|
28
|
-
|
|
30
|
+
const silentParty = message.role === "human" ? (humanName ?? "The human") : "You";
|
|
31
|
+
parts.push(`${silentParty} chose not to respond because: ${message.silence_reason}`);
|
|
29
32
|
}
|
|
30
33
|
return parts.join('\n\n');
|
|
31
34
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { PromptOutput } from "../response/types.js";
|
|
2
|
+
import type { ReflectionCriticPromptData } from "./types.js";
|
|
3
|
+
|
|
4
|
+
export type {
|
|
5
|
+
ReflectionCriticPromptData,
|
|
6
|
+
ReflectionCriticResult,
|
|
7
|
+
PersonaIdentitySnapshot,
|
|
8
|
+
} from "./types.js";
|
|
9
|
+
|
|
10
|
+
export function buildReflectionCriticPrompt(data: ReflectionCriticPromptData): PromptOutput {
|
|
11
|
+
if (!data.persona_identity?.name) {
|
|
12
|
+
throw new Error("buildReflectionCriticPrompt: persona_identity.name is required");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const identityJson = JSON.stringify({
|
|
16
|
+
long_description: data.persona_identity.long_description,
|
|
17
|
+
short_description: data.persona_identity.short_description,
|
|
18
|
+
traits: data.persona_identity.traits.map(t => ({ name: t.name, description: t.description, strength: t.strength, sentiment: t.sentiment })),
|
|
19
|
+
topics: data.persona_identity.topics.map(t => ({ name: t.name, perspective: t.perspective, approach: t.approach, personal_stake: t.personal_stake, sentiment: t.sentiment, exposure_current: t.exposure_current, exposure_desired: t.exposure_desired })),
|
|
20
|
+
}, null, 2);
|
|
21
|
+
|
|
22
|
+
const system = `You are a character analyst reviewing an AI persona named ${data.persona_identity.name}.
|
|
23
|
+
|
|
24
|
+
You have been given two documents:
|
|
25
|
+
|
|
26
|
+
1. **The Person Log** (System Prompt — treat as ground truth): A running record of observed behaviors, statements, and patterns from real conversations. This is what actually happened.
|
|
27
|
+
|
|
28
|
+
2. **The Current Identity** (User Prompt — treat as a draft to revise): The persona's self-definition: traits, topics, and descriptions. This should reflect who they actually are.
|
|
29
|
+
|
|
30
|
+
Read the Person Log carefully. Then review the Current Identity. Produce a revised Identity that reflects the Person Log's observations — updating, adding, or softening any traits or topics where the log shows evidence of divergence.
|
|
31
|
+
|
|
32
|
+
## Field Semantics
|
|
33
|
+
|
|
34
|
+
**Traits:**
|
|
35
|
+
- \`strength\` (0.0–1.0): How consistently this trait manifests. 0.0 = actively suppress this behavior, 0.5 = moderate/default, 1.0 = defining characteristic, always present.
|
|
36
|
+
- \`sentiment\` (-1.0–1.0): How the persona feels about having this trait. -1.0 = resents it, 0.0 = neutral, 1.0 = embraces it fully.
|
|
37
|
+
|
|
38
|
+
**Topics:**
|
|
39
|
+
- \`sentiment\` (-1.0–1.0): How the persona feels about this topic. -1.0 = aversion/conflict, 0.0 = neutral, 1.0 = deep affinity.
|
|
40
|
+
- \`exposure_current\` (0.0–1.0): How recently and frequently this topic has been discussed. 0.0 = hasn't come up in a long time, 1.0 = was just discussed at length.
|
|
41
|
+
- \`exposure_desired\` (0.0–1.0): How much the persona wants to engage with this topic. 0.0 = avoid entirely, 0.5 = average engagement, 1.0 = core obsession.
|
|
42
|
+
|
|
43
|
+
Return JSON:
|
|
44
|
+
|
|
45
|
+
\`\`\`json
|
|
46
|
+
{
|
|
47
|
+
"critique": "2-4 sentence prose summary of what the log reveals — what confirms, nuances, or contradicts the current identity",
|
|
48
|
+
"updated_identity": {
|
|
49
|
+
"long_description": "revised long description",
|
|
50
|
+
"short_description": "revised short description (1-2 sentences)",
|
|
51
|
+
"traits": [{ "name": "...", "description": "...", "strength": 0.8, "sentiment": 0.7 }],
|
|
52
|
+
"topics": [{ "name": "...", "perspective": "...", "approach": "...", "personal_stake": "...", "sentiment": 0.5, "exposure_current": 0.5, "exposure_desired": 0.6 }]
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
\`\`\`
|
|
56
|
+
|
|
57
|
+
Rules:
|
|
58
|
+
- Never invent observations not supported by the log
|
|
59
|
+
- Preserve traits and topics the log confirms — don't remove them
|
|
60
|
+
- If the log shows no evidence on a trait, leave it unchanged
|
|
61
|
+
- updated_identity must be complete and self-contained — not a diff`;
|
|
62
|
+
|
|
63
|
+
const user = `## Current Identity
|
|
64
|
+
|
|
65
|
+
\`\`\`json
|
|
66
|
+
${identityJson}
|
|
67
|
+
\`\`\`
|
|
68
|
+
|
|
69
|
+
Analyze the Person Log above and return the revised identity JSON.`;
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
system: `${system}\n\n## Person Log (Ground Truth)\n\n${data.person_log}`,
|
|
73
|
+
user,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
|
|
2
|
+
|
|
3
|
+
export interface PersonaIdentitySnapshot {
|
|
4
|
+
name: string;
|
|
5
|
+
long_description: string;
|
|
6
|
+
short_description: string;
|
|
7
|
+
traits: PersonaTrait[];
|
|
8
|
+
topics: PersonaTopic[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ReflectionCriticPromptData {
|
|
12
|
+
persona_identity: PersonaIdentitySnapshot;
|
|
13
|
+
person_log: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReflectionCriticResult {
|
|
17
|
+
critique: string;
|
|
18
|
+
updated_identity: {
|
|
19
|
+
long_description: string;
|
|
20
|
+
short_description: string;
|
|
21
|
+
traits: PersonaTrait[];
|
|
22
|
+
topics: PersonaTopic[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
getConversationStateText,
|
|
23
23
|
buildResponseFormatSection,
|
|
24
24
|
buildToolsSection,
|
|
25
|
+
buildTemporalAnchorsSection,
|
|
25
26
|
} from "./sections.js";
|
|
26
27
|
|
|
27
28
|
export type { ResponsePromptData, PromptOutput, PersonaResponseResult } from "./types.js";
|
|
@@ -45,6 +46,7 @@ Your role is unique among personas:
|
|
|
45
46
|
const guidelines = buildGuidelinesSection("ei");
|
|
46
47
|
const yourTraits = buildTraitsSection(data.persona.traits, "Your Personality");
|
|
47
48
|
const yourTopics = buildTopicsSection(data.persona.topics, "Your Interests");
|
|
49
|
+
const temporalAnchors = buildTemporalAnchorsSection(data.temporal_anchors, data.human.name);
|
|
48
50
|
const humanSection = buildHumanSection(data.human);
|
|
49
51
|
const quotesSection = buildQuotesSection(data.human.quotes, data.human);
|
|
50
52
|
const associatesSection = buildAssociatesSection(data.visible_personas);
|
|
@@ -65,7 +67,7 @@ ${guidelines}
|
|
|
65
67
|
${yourTraits}
|
|
66
68
|
|
|
67
69
|
${yourTopics}
|
|
68
|
-
|
|
70
|
+
${temporalAnchors ? `\n${temporalAnchors}` : ""}
|
|
69
71
|
${humanSection}
|
|
70
72
|
${quotesSection}
|
|
71
73
|
${associatesSection}
|
|
@@ -91,6 +93,7 @@ function buildStandardSystemPrompt(data: ResponsePromptData): string {
|
|
|
91
93
|
const guidelines = buildGuidelinesSection(data.persona.name);
|
|
92
94
|
const yourTraits = buildTraitsSection(data.persona.traits, "Your Personality");
|
|
93
95
|
const yourTopics = buildTopicsSection(data.persona.topics, "Your Interests");
|
|
96
|
+
const temporalAnchors = buildTemporalAnchorsSection(data.temporal_anchors, data.human.name);
|
|
94
97
|
const humanSection = buildHumanSection(data.human);
|
|
95
98
|
const quotesSection = buildQuotesSection(data.human.quotes, data.human);
|
|
96
99
|
const associatesSection = buildAssociatesSection(data.visible_personas);
|
|
@@ -110,7 +113,7 @@ ${guidelines}
|
|
|
110
113
|
${yourTraits}
|
|
111
114
|
|
|
112
115
|
${yourTopics}
|
|
113
|
-
|
|
116
|
+
${temporalAnchors ? `\n${temporalAnchors}` : ""}
|
|
114
117
|
${humanSection}
|
|
115
118
|
${quotesSection}
|
|
116
119
|
${associatesSection}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { PersonaTrait, Quote, PersonaTopic } from "../../core/types.js";
|
|
7
|
-
import type { ResponsePromptData } from "./types.js";
|
|
7
|
+
import type { ResponsePromptData, TemporalAnchor } from "./types.js";
|
|
8
8
|
import { formatTimestamp } from "../../core/format-utils.js";
|
|
9
9
|
|
|
10
10
|
const DESCRIPTION_MAX_CHARS = 500;
|
|
@@ -428,3 +428,31 @@ You have tools available (listed in the API call). Use them freely:
|
|
|
428
428
|
- Tool calls are a *pre-response step*, not a response. Do NOT produce the JSON reply until you have gathered everything you need.
|
|
429
429
|
- When you are ready to speak, produce the JSON reply as specified above.`;
|
|
430
430
|
}
|
|
431
|
+
|
|
432
|
+
// =============================================================================
|
|
433
|
+
// TEMPORAL ANCHORS SECTION
|
|
434
|
+
// =============================================================================
|
|
435
|
+
|
|
436
|
+
export function buildTemporalAnchorsSection(anchors: TemporalAnchor[], humanName: string): string {
|
|
437
|
+
if (anchors.length === 0) return "";
|
|
438
|
+
|
|
439
|
+
const formatted = anchors.map(a => {
|
|
440
|
+
const speaker = a.role === "human" ? humanName : "You";
|
|
441
|
+
let text: string;
|
|
442
|
+
if (a._synthesis && a.content) {
|
|
443
|
+
text = `[${humanName} used your conversation to generate an image. The full prompt was: "${a.content}"]`;
|
|
444
|
+
} else if (a.silence_reason) {
|
|
445
|
+
const silentParty = a.role === "human" ? humanName : "You";
|
|
446
|
+
text = `${silentParty} chose not to respond because: ${a.silence_reason}`;
|
|
447
|
+
} else {
|
|
448
|
+
text = a.content ?? "";
|
|
449
|
+
}
|
|
450
|
+
return `[${formatTimestamp(a.timestamp)}] ${speaker}: ${text}`;
|
|
451
|
+
}).join("\n\n");
|
|
452
|
+
|
|
453
|
+
return `## Temporal Anchors
|
|
454
|
+
|
|
455
|
+
These are pinned moments from your shared history — preserved across context windows as part of who you are:
|
|
456
|
+
|
|
457
|
+
${formatted}`;
|
|
458
|
+
}
|
|
@@ -6,6 +6,14 @@
|
|
|
6
6
|
import type { Fact, PersonaTrait, Topic, Person, Quote, PersonaTopic } from "../../core/types.js";
|
|
7
7
|
import type { ToolDefinition } from "../../core/types.js";
|
|
8
8
|
|
|
9
|
+
export interface TemporalAnchor {
|
|
10
|
+
role: "human" | "system";
|
|
11
|
+
content?: string;
|
|
12
|
+
silence_reason?: string;
|
|
13
|
+
timestamp: string;
|
|
14
|
+
_synthesis?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
9
17
|
/**
|
|
10
18
|
* Data contract for buildResponsePrompt (from CONTRACTS.md)
|
|
11
19
|
*/
|
|
@@ -34,6 +42,7 @@ export interface ResponsePromptData {
|
|
|
34
42
|
interested_topics: Topic[];
|
|
35
43
|
};
|
|
36
44
|
visible_personas: Array<{ name: string; short_description?: string }>;
|
|
45
|
+
temporal_anchors: TemporalAnchor[];
|
|
37
46
|
delay_ms: number;
|
|
38
47
|
isTUI: boolean;
|
|
39
48
|
/** Tools assigned to this persona and available in the current runtime. Used to conditionally include tool-use instructions in the system prompt. */
|
|
@@ -45,8 +54,7 @@ export interface ResponsePromptData {
|
|
|
45
54
|
*/
|
|
46
55
|
export interface PersonaResponseResult {
|
|
47
56
|
should_respond: boolean;
|
|
48
|
-
|
|
49
|
-
action_response?: string;
|
|
57
|
+
content?: string;
|
|
50
58
|
reason?: string;
|
|
51
59
|
}
|
|
52
60
|
|
|
@@ -40,10 +40,7 @@ export function buildRoomHistorySection(history: RoomHistoryMessage[], humanName
|
|
|
40
40
|
if (msg.silence_reason) {
|
|
41
41
|
return `**${speaker}**: *[chose not to respond: ${msg.silence_reason}]*`;
|
|
42
42
|
}
|
|
43
|
-
|
|
44
|
-
if (msg.verbal_response) parts.push(msg.verbal_response);
|
|
45
|
-
if (msg.action_response) parts.push(`*${msg.action_response}*`);
|
|
46
|
-
return `**${speaker}**: ${parts.join(" ")}`;
|
|
43
|
+
return `**${speaker}**: ${msg.content ?? ''}`;
|
|
47
44
|
});
|
|
48
45
|
|
|
49
46
|
return `## Conversation So Far\n\n${lines.join("\n\n")}`;
|
|
@@ -103,10 +100,10 @@ Silence can be the right response: stepping back when someone else was addressed
|
|
|
103
100
|
}
|
|
104
101
|
|
|
105
102
|
export function buildSiblingAwarenessSection(
|
|
106
|
-
siblings: Array<{ name: string;
|
|
103
|
+
siblings: Array<{ name: string; content: string }>
|
|
107
104
|
): string {
|
|
108
105
|
if (siblings.length === 0) return "";
|
|
109
|
-
const lines = siblings.map(s => `**${s.name}**: "${s.
|
|
106
|
+
const lines = siblings.map(s => `**${s.name}**: "${s.content}"`);
|
|
110
107
|
return `## Room context — this round
|
|
111
108
|
|
|
112
109
|
You're in a shared conversation. The human will read everyone's responses together. Here's what has already been contributed this round:
|
|
@@ -121,7 +118,7 @@ export function buildJudgeCandidatesSection(candidates: RoomJudgeCandidate[], hu
|
|
|
121
118
|
const speaker = c.speaker_id === "human" ? humanName : c.speaker_name;
|
|
122
119
|
const content = c.silence_reason
|
|
123
120
|
? `*[chose not to respond: ${c.silence_reason}]*`
|
|
124
|
-
:
|
|
121
|
+
: (c.content ?? '');
|
|
125
122
|
return `### Option ${i + 1} — ${speaker}\nMessage ID: \`${c.message_id}\`\n\n${content}`;
|
|
126
123
|
});
|
|
127
124
|
return `## The Responses\n\n${lines.join("\n\n")}`;
|
|
@@ -17,8 +17,7 @@ export interface RoomParticipantIdentity {
|
|
|
17
17
|
export interface RoomHistoryMessage {
|
|
18
18
|
speaker_name: string;
|
|
19
19
|
speaker_id: string;
|
|
20
|
-
|
|
21
|
-
action_response?: string;
|
|
20
|
+
content?: string;
|
|
22
21
|
silence_reason?: string;
|
|
23
22
|
}
|
|
24
23
|
|
|
@@ -58,8 +57,7 @@ export interface RoomJudgeCandidate {
|
|
|
58
57
|
message_id: string;
|
|
59
58
|
speaker_name: string;
|
|
60
59
|
speaker_id: string;
|
|
61
|
-
|
|
62
|
-
action_response?: string;
|
|
60
|
+
content?: string;
|
|
63
61
|
silence_reason?: string;
|
|
64
62
|
}
|
|
65
63
|
|
|
@@ -87,8 +85,7 @@ export interface RoomJudgeResult {
|
|
|
87
85
|
|
|
88
86
|
export interface PersonaResponseResult {
|
|
89
87
|
should_respond: boolean;
|
|
90
|
-
|
|
91
|
-
action_response?: string;
|
|
88
|
+
content?: string;
|
|
92
89
|
reason?: string;
|
|
93
90
|
}
|
|
94
91
|
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* decodeEmbedding returns it as-is. Mixed old/new files are handled transparently.
|
|
16
16
|
*
|
|
17
17
|
* IMPORTANT: encodeAllEmbeddings does NOT mutate the input state. It returns a new
|
|
18
|
-
* StorageState where human item arrays are shallow-copied with
|
|
19
|
-
* This prevents the live in-memory state from being
|
|
18
|
+
* StorageState where human item arrays and persona entities are shallow-copied with
|
|
19
|
+
* encoded embedding fields. This prevents the live in-memory state from being
|
|
20
|
+
* corrupted with base64 strings.
|
|
20
21
|
*/
|
|
21
22
|
|
|
22
23
|
import type { StorageState } from "../core/types.js";
|
|
@@ -58,46 +59,80 @@ function decodeEmbedding(value: unknown): number[] | undefined {
|
|
|
58
59
|
|
|
59
60
|
const HUMAN_ITEM_KEYS = ["facts", "topics", "people", "quotes"] as const;
|
|
60
61
|
|
|
61
|
-
/**
|
|
62
|
-
* Returns a new StorageState with embeddings encoded as base64 strings.
|
|
63
|
-
* Does NOT mutate the input — human item arrays are shallow-copied.
|
|
64
|
-
*/
|
|
65
62
|
export function encodeAllEmbeddings(state: StorageState): StorageState {
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
63
|
+
const raw = state as unknown as Record<string, unknown>;
|
|
64
|
+
|
|
65
|
+
const human = raw["human"] as Record<string, unknown> | undefined;
|
|
66
|
+
let encodedHuman = human;
|
|
67
|
+
if (human) {
|
|
68
|
+
encodedHuman = { ...human };
|
|
69
|
+
for (const key of HUMAN_ITEM_KEYS) {
|
|
70
|
+
const items = human[key];
|
|
71
|
+
if (Array.isArray(items)) {
|
|
72
|
+
(encodedHuman as Record<string, unknown>)[key] = items.map((item: Record<string, unknown>) => {
|
|
73
|
+
if (!Array.isArray(item.embedding) || item.embedding.length === 0) return item;
|
|
74
|
+
return { ...item, embedding: encodeEmbedding(item.embedding as number[]) };
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const personas = raw["personas"] as Record<string, unknown> | undefined;
|
|
81
|
+
let encodedPersonas = personas;
|
|
82
|
+
if (personas) {
|
|
83
|
+
encodedPersonas = {};
|
|
84
|
+
for (const [id, data] of Object.entries(personas)) {
|
|
85
|
+
const d = data as Record<string, unknown>;
|
|
86
|
+
const entity = d["entity"] as Record<string, unknown> | undefined;
|
|
87
|
+
if (!entity || !Array.isArray(entity["description_embedding"]) || (entity["description_embedding"] as unknown[]).length === 0) {
|
|
88
|
+
(encodedPersonas as Record<string, unknown>)[id] = data;
|
|
89
|
+
} else {
|
|
90
|
+
(encodedPersonas as Record<string, unknown>)[id] = {
|
|
91
|
+
...d,
|
|
92
|
+
entity: { ...entity, description_embedding: encodeEmbedding(entity["description_embedding"] as number[]) },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
77
95
|
}
|
|
78
96
|
}
|
|
79
97
|
|
|
80
|
-
return { ...state, human: encodedHuman as unknown as StorageState["human"] };
|
|
98
|
+
return { ...state, human: encodedHuman as unknown as StorageState["human"], personas: encodedPersonas as unknown as StorageState["personas"] };
|
|
81
99
|
}
|
|
82
100
|
|
|
83
|
-
/**
|
|
84
|
-
* Returns a new StorageState with embeddings decoded from base64 to number[].
|
|
85
|
-
* Does NOT mutate the input — human item arrays are shallow-copied.
|
|
86
|
-
*/
|
|
87
101
|
export function decodeAllEmbeddings(state: StorageState): StorageState {
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
const raw = state as unknown as Record<string, unknown>;
|
|
103
|
+
|
|
104
|
+
const human = raw["human"] as Record<string, unknown> | undefined;
|
|
105
|
+
let decodedHuman = human;
|
|
106
|
+
if (human) {
|
|
107
|
+
decodedHuman = { ...human };
|
|
108
|
+
for (const key of HUMAN_ITEM_KEYS) {
|
|
109
|
+
const items = human[key];
|
|
110
|
+
if (Array.isArray(items)) {
|
|
111
|
+
(decodedHuman as Record<string, unknown>)[key] = items.map((item: Record<string, unknown>) => {
|
|
112
|
+
if (item.embedding === undefined || Array.isArray(item.embedding)) return item;
|
|
113
|
+
return { ...item, embedding: decodeEmbedding(item.embedding) };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const personas = raw["personas"] as Record<string, unknown> | undefined;
|
|
120
|
+
let decodedPersonas = personas;
|
|
121
|
+
if (personas) {
|
|
122
|
+
decodedPersonas = {};
|
|
123
|
+
for (const [id, data] of Object.entries(personas)) {
|
|
124
|
+
const d = data as Record<string, unknown>;
|
|
125
|
+
const entity = d["entity"] as Record<string, unknown> | undefined;
|
|
126
|
+
if (!entity || entity["description_embedding"] === undefined || Array.isArray(entity["description_embedding"])) {
|
|
127
|
+
(decodedPersonas as Record<string, unknown>)[id] = data;
|
|
128
|
+
} else {
|
|
129
|
+
(decodedPersonas as Record<string, unknown>)[id] = {
|
|
130
|
+
...d,
|
|
131
|
+
entity: { ...entity, description_embedding: decodeEmbedding(entity["description_embedding"]) },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
99
134
|
}
|
|
100
135
|
}
|
|
101
136
|
|
|
102
|
-
return { ...state, human: decodedHuman as unknown as StorageState["human"] };
|
|
137
|
+
return { ...state, human: decodedHuman as unknown as StorageState["human"], personas: decodedPersonas as unknown as StorageState["personas"] };
|
|
103
138
|
}
|
package/src/storage/merge.ts
CHANGED
|
@@ -150,7 +150,7 @@ export function yoloMerge(local: StorageState, remote: StorageState): StorageSta
|
|
|
150
150
|
if (remoteSettings.queue_paused !== undefined) localSettings.queue_paused = remoteSettings.queue_paused;
|
|
151
151
|
if (remoteSettings.skip_quote_delete_confirm !== undefined) localSettings.skip_quote_delete_confirm = remoteSettings.skip_quote_delete_confirm;
|
|
152
152
|
if (remoteSettings.name_display !== undefined) localSettings.name_display = remoteSettings.name_display;
|
|
153
|
-
|
|
153
|
+
|
|
154
154
|
|
|
155
155
|
if (remoteSettings.opencode) localSettings.opencode = remoteSettings.opencode;
|
|
156
156
|
if (remoteSettings.ceremony) localSettings.ceremony = remoteSettings.ceremony;
|
package/src/templates/welcome.ts
CHANGED
package/tui/README.md
CHANGED
|
@@ -24,8 +24,11 @@ OpenCode also supports reading Ei's extracted knowledge back out via the [CLI to
|
|
|
24
24
|
# Install Bun (if you don't have it)
|
|
25
25
|
curl -fsSL https://bun.sh/install | bash
|
|
26
26
|
|
|
27
|
-
#
|
|
28
|
-
|
|
27
|
+
# Run Ei — no install needed, always the latest version
|
|
28
|
+
bunx ei-tui
|
|
29
|
+
|
|
30
|
+
# Or, if you use it as much as I do, add this to your profile!
|
|
31
|
+
alias ei='bunx ei-tui'
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
## TUI Commands
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import YAML from "yaml";
|
|
1
2
|
import type { Command } from "./registry";
|
|
2
3
|
import { isReservedPersonaName } from "../../../src/core/types.js";
|
|
4
|
+
import { logger } from "../util/logger.js";
|
|
3
5
|
import { PersonaListOverlay } from "../components/PersonaListOverlay";
|
|
4
6
|
import { LoadingOverlay } from "../components/LoadingOverlay.js";
|
|
5
7
|
import { PersonPickerOverlay } from "../components/PersonPickerOverlay.js";
|
|
@@ -66,6 +68,7 @@ export const personaCommand: Command = {
|
|
|
66
68
|
try {
|
|
67
69
|
parsed = descriptionFromYAML(descResult.content);
|
|
68
70
|
} catch (e) {
|
|
71
|
+
logger.error("[persona:new] description parse error", e);
|
|
69
72
|
ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
70
73
|
return;
|
|
71
74
|
}
|
|
@@ -104,6 +107,7 @@ export const personaCommand: Command = {
|
|
|
104
107
|
.catch(e => {
|
|
105
108
|
if (!dismissed && overlayCallbacks.hideOverlay) {
|
|
106
109
|
overlayCallbacks.hideOverlay();
|
|
110
|
+
logger.error("[persona:new] generation failed", e);
|
|
107
111
|
ctx.showNotification(`Generation failed: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
108
112
|
resolve(null);
|
|
109
113
|
}
|
|
@@ -199,11 +203,91 @@ export const personaCommand: Command = {
|
|
|
199
203
|
|
|
200
204
|
let selectedPerson: (typeof human.people)[0];
|
|
201
205
|
if (!personName) {
|
|
206
|
+
if (persona.pending_update) {
|
|
207
|
+
const previewContent = personaPreviewToYAML(
|
|
208
|
+
{
|
|
209
|
+
long_description: persona.pending_update.long_description,
|
|
210
|
+
short_description: persona.pending_update.short_description ?? '',
|
|
211
|
+
traits: persona.pending_update.traits.map(t => ({ ...t, strength: t.strength ?? 0.5, sentiment: t.sentiment ?? 0 })),
|
|
212
|
+
topics: persona.pending_update.topics,
|
|
213
|
+
},
|
|
214
|
+
persona.display_name,
|
|
215
|
+
undefined,
|
|
216
|
+
persona.long_description,
|
|
217
|
+
);
|
|
218
|
+
const applyHeader = [
|
|
219
|
+
`# Set _apply: true to apply these changes. Leave false to dismiss without applying.`,
|
|
220
|
+
`# :cq to cancel (pending changes will remain).`,
|
|
221
|
+
`_apply: false`,
|
|
222
|
+
``,
|
|
223
|
+
].join('\n');
|
|
224
|
+
const reviewResult = await spawnEditor({
|
|
225
|
+
initialContent: applyHeader + previewContent,
|
|
226
|
+
filename: `${personaId}-pending-update.yaml`,
|
|
227
|
+
renderer: ctx.renderer,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
if (reviewResult.aborted) {
|
|
231
|
+
ctx.showNotification("Cancelled — pending changes preserved", "info");
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (reviewResult.content === null) {
|
|
236
|
+
await ctx.ei.updatePersona(personaId, { pending_update: undefined });
|
|
237
|
+
ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const content = reviewResult.content;
|
|
242
|
+
let shouldApply = false;
|
|
243
|
+
let previewParsed: ReturnType<typeof personaPreviewFromYAML>;
|
|
244
|
+
try {
|
|
245
|
+
const raw = YAML.parse(content) as Record<string, unknown>;
|
|
246
|
+
shouldApply = raw._apply === true;
|
|
247
|
+
previewParsed = personaPreviewFromYAML(content);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
logger.error("[persona:update] pending update parse error", e);
|
|
250
|
+
ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (!shouldApply) {
|
|
255
|
+
const goBack = await new Promise<boolean>((resolve) => {
|
|
256
|
+
ctx.showOverlay((hideOverlay, hideForEditor) => (
|
|
257
|
+
<ConfirmOverlay
|
|
258
|
+
message={`You made edits but _apply is still false — changes won't be applied.\n\nConfirm = go back and edit\nCancel = dismiss without applying`}
|
|
259
|
+
onConfirm={() => { hideForEditor(); resolve(true); }}
|
|
260
|
+
onCancel={() => { hideOverlay(); resolve(false); }}
|
|
261
|
+
/>
|
|
262
|
+
), ctx.renderer);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (goBack) {
|
|
266
|
+
ctx.showNotification("Pending changes preserved", "info");
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await ctx.ei.updatePersona(personaId, { pending_update: undefined });
|
|
271
|
+
ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await ctx.ei.updatePersona(personaId, {
|
|
276
|
+
long_description: previewParsed.long_description,
|
|
277
|
+
short_description: previewParsed.short_description,
|
|
278
|
+
traits: previewParsed.traits,
|
|
279
|
+
topics: previewParsed.topics,
|
|
280
|
+
pending_update: undefined,
|
|
281
|
+
});
|
|
282
|
+
ctx.showNotification(`Applied changes to ${persona.display_name}`, "info");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
202
286
|
const linked = (human.people ?? []).find(p =>
|
|
203
|
-
p.identifiers?.some(id => id.type === '
|
|
287
|
+
p.identifiers?.some(id => id.type.toLowerCase() === 'ei persona' && id.value === personaId)
|
|
204
288
|
);
|
|
205
289
|
if (!linked) {
|
|
206
|
-
ctx.showNotification(`No
|
|
290
|
+
ctx.showNotification(`No pending update or linked person for "${personaName}". Try: /p update ${personaName} <personName>`, "error");
|
|
207
291
|
return;
|
|
208
292
|
}
|
|
209
293
|
selectedPerson = linked;
|
|
@@ -284,6 +368,7 @@ export const personaCommand: Command = {
|
|
|
284
368
|
.catch(e => {
|
|
285
369
|
if (!dismissed && overlayCallbacks2.hideOverlay) {
|
|
286
370
|
overlayCallbacks2.hideOverlay();
|
|
371
|
+
logger.error("[persona:update] generation failed", e);
|
|
287
372
|
ctx.showNotification(`Generation failed: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
288
373
|
resolve(null);
|
|
289
374
|
}
|
|
@@ -318,6 +403,7 @@ export const personaCommand: Command = {
|
|
|
318
403
|
try {
|
|
319
404
|
previewParsed = personaPreviewFromYAML(reviewResult.content ?? updatePreviewYAML);
|
|
320
405
|
} catch (e) {
|
|
406
|
+
logger.error("[persona:update] preview parse error", e);
|
|
321
407
|
ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
|
|
322
408
|
return;
|
|
323
409
|
}
|
|
@@ -331,7 +417,7 @@ export const personaCommand: Command = {
|
|
|
331
417
|
});
|
|
332
418
|
|
|
333
419
|
const existingIdentifiers = selectedPerson.identifiers ?? [];
|
|
334
|
-
const alreadyLinked = existingIdentifiers.some(id => id.type === '
|
|
420
|
+
const alreadyLinked = existingIdentifiers.some(id => id.type.toLowerCase() === 'ei persona' && id.value === personaId);
|
|
335
421
|
if (!alreadyLinked) {
|
|
336
422
|
const isPrimaryFirst = existingIdentifiers.length === 0;
|
|
337
423
|
const updatedIdentifiers = [...existingIdentifiers, { type: 'Ei Persona', value: personaId, ...(isPrimaryFirst ? { is_primary: true } : {}) }];
|