ei-tui 0.8.1 → 0.9.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.
Files changed (62) hide show
  1. package/README.md +9 -6
  2. package/package.json +1 -1
  3. package/src/core/handlers/heartbeat.ts +63 -8
  4. package/src/core/handlers/index.ts +2 -1
  5. package/src/core/handlers/persona-response.ts +3 -5
  6. package/src/core/handlers/rooms.ts +3 -5
  7. package/src/core/handlers/utils.ts +5 -4
  8. package/src/core/heartbeat-manager.ts +16 -47
  9. package/src/core/message-manager.ts +6 -2
  10. package/src/core/orchestrators/ceremony.ts +49 -4
  11. package/src/core/persona-manager.ts +1 -2
  12. package/src/core/personas/opencode-agent.ts +7 -2
  13. package/src/core/processor.ts +5 -12
  14. package/src/core/prompt-context-builder.ts +11 -1
  15. package/src/core/queue-processor.ts +4 -4
  16. package/src/core/room-manager.ts +6 -6
  17. package/src/core/state/human.ts +0 -1
  18. package/src/core/state/personas.ts +22 -13
  19. package/src/core/state/rooms.ts +0 -2
  20. package/src/core/state-manager.ts +83 -11
  21. package/src/core/types/data-items.ts +2 -2
  22. package/src/core/types/entities.ts +8 -3
  23. package/src/core/types/enums.ts +1 -0
  24. package/src/core/types/integrations.ts +1 -1
  25. package/src/core/types/llm.ts +2 -4
  26. package/src/core/types/rooms.ts +0 -4
  27. package/src/integrations/claude-code/importer.ts +1 -5
  28. package/src/integrations/cursor/importer.ts +1 -5
  29. package/src/integrations/opencode/importer.ts +1 -4
  30. package/src/integrations/opencode/types.ts +17 -1
  31. package/src/prompts/heartbeat/check.ts +7 -18
  32. package/src/prompts/heartbeat/ei.ts +14 -0
  33. package/src/prompts/heartbeat/types.ts +7 -5
  34. package/src/prompts/index.ts +9 -0
  35. package/src/prompts/message-utils.ts +7 -4
  36. package/src/prompts/reflection/index.ts +77 -0
  37. package/src/prompts/reflection/types.ts +26 -0
  38. package/src/prompts/response/index.ts +5 -2
  39. package/src/prompts/response/sections.ts +29 -1
  40. package/src/prompts/response/types.ts +10 -2
  41. package/src/prompts/room/sections.ts +4 -7
  42. package/src/prompts/room/types.ts +3 -6
  43. package/src/storage/embeddings.ts +69 -34
  44. package/src/storage/merge.ts +1 -1
  45. package/src/templates/welcome.ts +0 -1
  46. package/tui/README.md +5 -2
  47. package/tui/src/commands/editor.tsx +0 -1
  48. package/tui/src/commands/persona.tsx +89 -3
  49. package/tui/src/commands/reflect.tsx +375 -0
  50. package/tui/src/commands/registry.ts +2 -0
  51. package/tui/src/components/CYPTreeOverlay.tsx +0 -2
  52. package/tui/src/components/MAPScoreOverlay.tsx +1 -1
  53. package/tui/src/components/MessageList.tsx +8 -10
  54. package/tui/src/components/PromptInput.tsx +3 -1
  55. package/tui/src/components/RoomMessageList.tsx +5 -8
  56. package/tui/src/components/Sidebar.tsx +3 -5
  57. package/tui/src/components/StatusBar.tsx +26 -14
  58. package/tui/src/context/keyboard.tsx +2 -2
  59. package/tui/src/util/cyp-editor.tsx +2 -6
  60. package/tui/src/util/yaml-context.ts +2 -6
  61. package/tui/src/util/yaml-persona.ts +3 -3
  62. package/tui/src/util/yaml-settings.ts +0 -3
@@ -0,0 +1,375 @@
1
+ import { useKeyboard } from "@opentui/solid";
2
+ import { onMount, onCleanup } from "solid-js";
3
+ import * as fs from "fs";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+ import type { Command, CommandContext } from "./registry";
7
+ import type { PersonaEntity } from "../../../src/core/types.js";
8
+ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
9
+ import {
10
+ personaPreviewToYAML,
11
+ personaPreviewFromYAML,
12
+ } from "../util/yaml-serializers.js";
13
+ import { useKeyboardNav } from "../context/keyboard.js";
14
+
15
+ function wrapComment(text: string, width = 78): string {
16
+ const words = text.split(/\s+/);
17
+ const lines: string[] = [];
18
+ let current = "";
19
+ for (const word of words) {
20
+ if (current && (current + " " + word).length > width) {
21
+ lines.push(`# ${current}`);
22
+ current = word;
23
+ } else {
24
+ current = current ? `${current} ${word}` : word;
25
+ }
26
+ }
27
+ if (current) lines.push(`# ${current}`);
28
+ return lines.join("\n");
29
+ }
30
+
31
+ function getDataPath(): string {
32
+ const raw = process.env.EI_DATA_PATH ??
33
+ join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "ei");
34
+ return raw.replace(/\/+$/, "");
35
+ }
36
+
37
+ function getReflectFolder(persona: PersonaEntity): string {
38
+ const datePrefix = persona.pending_update!.created_at.slice(0, 10);
39
+ const safeName = persona.display_name.replace(/\s+/g, "_");
40
+ return join(getDataPath(), "reflect", `${datePrefix}_${safeName}`);
41
+ }
42
+
43
+ async function resolveReflectionPersona(
44
+ args: string[],
45
+ ctx: CommandContext
46
+ ): Promise<{ personaId: string; persona: PersonaEntity } | null> {
47
+ const SUBCOMMANDS = new Set(["generate", "update", "apply", "dismiss"]);
48
+ const nameArgs = SUBCOMMANDS.has(args[0]?.toLowerCase() ?? "") ? args.slice(1) : args;
49
+ const name = nameArgs.join(" ").trim();
50
+
51
+ let personaId: string | null;
52
+ if (name) {
53
+ personaId = await ctx.ei.resolvePersonaName(name);
54
+ if (!personaId) {
55
+ ctx.showNotification(`No persona named "${name}"`, "error");
56
+ return null;
57
+ }
58
+ } else {
59
+ personaId = ctx.ei.activePersonaId();
60
+ if (!personaId) {
61
+ ctx.showNotification("No active persona", "error");
62
+ return null;
63
+ }
64
+ }
65
+
66
+ const persona = await ctx.ei.getPersona(personaId);
67
+ if (!persona) {
68
+ ctx.showNotification("Could not load persona", "error");
69
+ return null;
70
+ }
71
+
72
+ if (!persona.pending_update) {
73
+ ctx.showNotification(`No pending reflection for ${persona.display_name}`, "error");
74
+ return null;
75
+ }
76
+
77
+ return { personaId, persona };
78
+ }
79
+
80
+ export const reflectCommand: Command = {
81
+ name: "reflect",
82
+ aliases: ["rf"],
83
+ description:
84
+ "Review a persona's pending reflection — compare current vs proposed identity while chatting",
85
+ usage: "/reflect [generate|update|apply|dismiss]",
86
+
87
+ async execute(args, ctx) {
88
+ const subcommand = args[0]?.toLowerCase();
89
+
90
+ if (!subcommand || !["generate", "update", "apply", "dismiss"].includes(subcommand)) {
91
+ const activeId = ctx.ei.activePersonaId();
92
+ const activePersona = activeId ? ctx.ei.personas().find(p => p.id === activeId) : null;
93
+ const hasPending = activePersona?.has_pending_update ?? false;
94
+ const pendingPersonas = hasPending
95
+ ? []
96
+ : ctx.ei.personas().filter(p => p.has_pending_update);
97
+
98
+ if (!hasPending && pendingPersonas.length === 0) {
99
+ ctx.showNotification("No pending reflections.", "info");
100
+ return;
101
+ }
102
+
103
+ const headerName = hasPending ? activePersona!.display_name : undefined;
104
+ const pendingNames = pendingPersonas.map(p => p.display_name);
105
+ const dataPath = getDataPath();
106
+
107
+ ctx.showOverlay((hideOverlay, _hideForEditor) => {
108
+ const { setOverlayActive } = useKeyboardNav();
109
+ onMount(() => setOverlayActive(true));
110
+ onCleanup(() => setOverlayActive(false));
111
+ useKeyboard((event) => {
112
+ event.preventDefault();
113
+ hideOverlay();
114
+ });
115
+
116
+ const header = headerName
117
+ ? `✦ Persona Reflection: ${headerName}`
118
+ : `✦ Persona Reflection — pending: ${pendingNames.join(", ")}`;
119
+
120
+ return (
121
+ <box
122
+ position="absolute"
123
+ width="100%"
124
+ height="100%"
125
+ left={0}
126
+ top={0}
127
+ backgroundColor="#000000"
128
+ alignItems="center"
129
+ justifyContent="center"
130
+ >
131
+ <box
132
+ width={64}
133
+ backgroundColor="#1a1a2e"
134
+ borderStyle="single"
135
+ borderColor="#586e75"
136
+ padding={2}
137
+ flexDirection="column"
138
+ >
139
+ <text fg="#eee8d5">{header}</text>
140
+ <text> </text>
141
+ <text fg="#839496">{"Personas grow through conversation. Reflection is a chance"}</text>
142
+ <text fg="#839496">{"to review proposed changes to their Identity with them."}</text>
143
+ <text fg="#839496">{"Open the generated files in your editor while you chat."}</text>
144
+ <text> </text>
145
+ <text fg="#268bd2">{" /reflect generate Write current + proposed YAML files"}</text>
146
+ <text fg="#268bd2">{" /reflect update Read proposed.yaml back into Ei"}</text>
147
+ <text fg="#268bd2">{" (Persona sees updated data)"}</text>
148
+ <text fg="#268bd2">{" /reflect apply Write proposed.yaml to your Persona"}</text>
149
+ <text fg="#268bd2">{" /reflect dismiss Discard without changing anything"}</text>
150
+ <text> </text>
151
+ <text fg="#839496">{dataPath.replace(homedir(), "~") + "/reflect/YYYY-MM-DD_Name/"}</text>
152
+ <text fg="#839496">{" ├── current.yaml # Read-Only Reference"}</text>
153
+ <text fg="#839496">{" └── proposed.yaml # Edit while Chatting"}</text>
154
+ <text> </text>
155
+ <text fg="#586e75">{"[Press any key to close]"}</text>
156
+ </box>
157
+ </box>
158
+ );
159
+ }, ctx.renderer);
160
+ return;
161
+ }
162
+
163
+ if (subcommand === "generate") {
164
+ const result = await resolveReflectionPersona(args, ctx);
165
+ if (!result) return;
166
+ const { persona } = result;
167
+
168
+ const folderPath = getReflectFolder(persona);
169
+
170
+ if (fs.existsSync(folderPath)) {
171
+ const date = persona.pending_update!.created_at.slice(0, 10);
172
+ ctx.showNotification(`Found existing reflection from ${date}, regenerating...`, "info");
173
+ fs.rmSync(folderPath, { recursive: true, force: true });
174
+ }
175
+ fs.mkdirSync(folderPath, { recursive: true });
176
+
177
+ const now = new Date().toISOString();
178
+ const critique = wrapComment(persona.pending_update!.critique);
179
+
180
+ const currentHeader = [
181
+ `# Current Identity: ${persona.display_name}`,
182
+ `# This is the CURRENT identity — for reference only. Do not edit this file.`,
183
+ `# Generated: ${now}`,
184
+ `#`,
185
+ `# Critique from reflection analysis:`,
186
+ critique,
187
+ ``,
188
+ ].join("\n");
189
+
190
+ const currentYAML = personaPreviewToYAML(
191
+ {
192
+ long_description: persona.long_description ?? "",
193
+ short_description: persona.short_description ?? "",
194
+ traits: (persona.traits ?? []).map(t => ({
195
+ name: t.name,
196
+ description: t.description,
197
+ strength: t.strength ?? 0.5,
198
+ sentiment: t.sentiment ?? 0,
199
+ })),
200
+ topics: (persona.topics ?? []).map(t => ({
201
+ name: t.name,
202
+ perspective: t.perspective,
203
+ approach: t.approach,
204
+ personal_stake: t.personal_stake,
205
+ sentiment: t.sentiment ?? 0,
206
+ exposure_current: t.exposure_current ?? 0.5,
207
+ exposure_desired: t.exposure_desired ?? 0.5,
208
+ })),
209
+ },
210
+ persona.display_name
211
+ );
212
+
213
+ fs.writeFileSync(join(folderPath, "current.yaml"), currentHeader + currentYAML, "utf-8");
214
+
215
+ const proposedHeader = [
216
+ `# Proposed Identity: ${persona.display_name}`,
217
+ `# Edit this file freely. Use /reflect update to sync changes into the system.`,
218
+ `# Use /reflect apply when done, or /reflect dismiss to discard.`,
219
+ ``,
220
+ ].join("\n");
221
+
222
+ const proposedYAML = personaPreviewToYAML(
223
+ {
224
+ long_description: persona.pending_update!.long_description,
225
+ short_description: persona.pending_update!.short_description ?? "",
226
+ traits: (persona.pending_update!.traits ?? []).map(t => ({
227
+ name: t.name,
228
+ description: t.description,
229
+ strength: t.strength ?? 0.5,
230
+ sentiment: t.sentiment ?? 0,
231
+ })),
232
+ topics: (persona.pending_update!.topics ?? []).map(t => ({
233
+ name: t.name,
234
+ perspective: t.perspective,
235
+ approach: t.approach,
236
+ personal_stake: t.personal_stake,
237
+ sentiment: t.sentiment ?? 0,
238
+ exposure_current: t.exposure_current ?? 0.5,
239
+ exposure_desired: t.exposure_desired ?? 0.5,
240
+ })),
241
+ },
242
+ persona.display_name
243
+ );
244
+
245
+ fs.writeFileSync(join(folderPath, "proposed.yaml"), proposedHeader + proposedYAML, "utf-8");
246
+
247
+ ctx.showNotification(
248
+ `Reflection files written to ${folderPath} — open proposed.yaml in your editor alongside this chat`,
249
+ "info"
250
+ );
251
+ return;
252
+ }
253
+
254
+ if (subcommand === "update") {
255
+ const result = await resolveReflectionPersona(args, ctx);
256
+ if (!result) return;
257
+ const { personaId, persona } = result;
258
+
259
+ const folderPath = getReflectFolder(persona);
260
+ if (!fs.existsSync(folderPath)) {
261
+ ctx.showNotification(
262
+ `No reflection folder found — run /reflect generate first`,
263
+ "error"
264
+ );
265
+ return;
266
+ }
267
+
268
+ const proposedContent = fs.readFileSync(join(folderPath, "proposed.yaml"), "utf-8");
269
+
270
+ let parsed: ReturnType<typeof personaPreviewFromYAML>;
271
+ try {
272
+ parsed = personaPreviewFromYAML(proposedContent);
273
+ } catch (e) {
274
+ ctx.showNotification(
275
+ `Parse error: ${e instanceof Error ? e.message : String(e)}`,
276
+ "error"
277
+ );
278
+ return;
279
+ }
280
+
281
+ await ctx.ei.updatePersona(personaId, {
282
+ pending_update: {
283
+ critique: persona.pending_update!.critique,
284
+ created_at: persona.pending_update!.created_at,
285
+ long_description: parsed.long_description,
286
+ short_description: parsed.short_description ?? persona.pending_update!.short_description,
287
+ traits: parsed.traits,
288
+ topics: parsed.topics,
289
+ },
290
+ });
291
+
292
+ ctx.showNotification(
293
+ `Updated — ${persona.display_name} will see your changes in the next message`,
294
+ "info"
295
+ );
296
+ return;
297
+ }
298
+
299
+ if (subcommand === "apply") {
300
+ const result = await resolveReflectionPersona(args, ctx);
301
+ if (!result) return;
302
+ const { personaId, persona } = result;
303
+
304
+ const folderPath = getReflectFolder(persona);
305
+
306
+ let finalParsed: ReturnType<typeof personaPreviewFromYAML> | null = null;
307
+ if (fs.existsSync(folderPath)) {
308
+ const proposedPath = join(folderPath, "proposed.yaml");
309
+ if (fs.existsSync(proposedPath)) {
310
+ const proposedContent = fs.readFileSync(proposedPath, "utf-8");
311
+ try {
312
+ finalParsed = personaPreviewFromYAML(proposedContent);
313
+ } catch (e) {
314
+ ctx.showNotification(
315
+ `Parse error in proposed.yaml: ${e instanceof Error ? e.message : String(e)}`,
316
+ "error"
317
+ );
318
+ return;
319
+ }
320
+ }
321
+ }
322
+
323
+ const source = finalParsed ?? {
324
+ long_description: persona.pending_update!.long_description,
325
+ short_description: persona.pending_update!.short_description as string | undefined,
326
+ traits: persona.pending_update!.traits,
327
+ topics: persona.pending_update!.topics,
328
+ };
329
+
330
+ await ctx.ei.updatePersona(personaId, {
331
+ long_description: source.long_description,
332
+ short_description: source.short_description,
333
+ traits: source.traits,
334
+ topics: source.topics,
335
+ pending_update: undefined,
336
+ });
337
+
338
+ if (fs.existsSync(folderPath)) {
339
+ fs.rmSync(folderPath, { recursive: true, force: true });
340
+ }
341
+
342
+ ctx.showNotification(`Applied reflection for ${persona.display_name}`, "info");
343
+ return;
344
+ }
345
+
346
+ if (subcommand === "dismiss") {
347
+ const result = await resolveReflectionPersona(args, ctx);
348
+ if (!result) return;
349
+ const { personaId, persona } = result;
350
+
351
+ const confirmed = await new Promise<boolean>((resolve) => {
352
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
353
+ <ConfirmOverlay
354
+ message={`Discard this reflection for ${persona.display_name}? The proposed identity will be lost.`}
355
+ onConfirm={() => { hideOverlay(); resolve(true); }}
356
+ onCancel={() => { hideOverlay(); resolve(false); }}
357
+ />
358
+ ), ctx.renderer);
359
+ });
360
+
361
+ if (!confirmed) {
362
+ ctx.showNotification("Cancelled", "info");
363
+ return;
364
+ }
365
+
366
+ await ctx.ei.updatePersona(personaId, { pending_update: undefined });
367
+ const folderPath = getReflectFolder(persona);
368
+ if (fs.existsSync(folderPath)) {
369
+ fs.rmSync(folderPath, { recursive: true, force: true });
370
+ }
371
+ ctx.showNotification(`Dismissed reflection for ${persona.display_name}`, "info");
372
+ return;
373
+ }
374
+ },
375
+ };
@@ -1,6 +1,7 @@
1
1
  import type { OverlayRenderer } from "../context/overlay";
2
2
  import type { EiContextValue } from "../context/ei";
3
3
  import type { CliRenderer } from "@opentui/core";
4
+ import { logger } from "../util/logger";
4
5
 
5
6
  export interface Command {
6
7
  name: string;
@@ -116,6 +117,7 @@ export async function parseAndExecute(
116
117
  await command.execute(args, ctx);
117
118
  } catch (error) {
118
119
  const errorMsg = error instanceof Error ? error.message : String(error);
120
+ logger.error(`[command:${commandName}] ${errorMsg}`, error);
119
121
  ctx.showNotification(`Command failed: ${errorMsg}`, "error");
120
122
  }
121
123
 
@@ -31,8 +31,6 @@ interface TreeLine {
31
31
 
32
32
  function getMessageContent(m: RoomMessage): string {
33
33
  if (m.content) return m.content;
34
- if (m.verbal_response) return m.verbal_response;
35
- if (m.action_response) return m.action_response;
36
34
  if (m.silence_reason) return `(${m.silence_reason})`;
37
35
  return "";
38
36
  }
@@ -32,7 +32,7 @@ function truncate(s: string, n: number): string {
32
32
  }
33
33
 
34
34
  function getMessageText(m: RoomMessage): string {
35
- return (m.verbal_response ?? m.content ?? m.silence_reason ?? "").replace(/\n+/g, " ");
35
+ return (m.content ?? m.silence_reason ?? "").replace(/\n+/g, " ");
36
36
  }
37
37
 
38
38
  export function MAPScoreOverlay(props: MAPScoreOverlayProps) {
@@ -19,17 +19,14 @@ function formatTime(timestamp: string): string {
19
19
  return `${hours}:${minutes}`;
20
20
  }
21
21
 
22
- function getContent(msg: { content?: string; verbal_response?: string; action_response?: string }): string {
23
- if (msg.content) return msg.content;
24
- const parts: string[] = [];
25
- if (msg.action_response) parts.push(`_${msg.action_response}_`);
26
- if (msg.verbal_response) parts.push(msg.verbal_response);
27
- return parts.join('\n\n');
22
+ function getContent(msg: { content?: string }): string {
23
+ return msg.content ?? '';
28
24
  }
29
25
 
30
- function buildMessageText(message: Message): string {
26
+ function buildMessageText(message: Message, humanName: string): string {
31
27
  if (message.silence_reason !== undefined) {
32
- return `[chose not to respond: ${message.silence_reason}]`;
28
+ const silentParty = message.role === "human" ? humanName : "Persona";
29
+ return `[${silentParty} chose not to respond: ${message.silence_reason}]`;
33
30
  }
34
31
  return getContent(message);
35
32
  }
@@ -116,6 +113,7 @@ export function MessageList() {
116
113
  stickyScroll={true}
117
114
  stickyStart="bottom"
118
115
  viewportCulling={true}
116
+ wrapperOptions={{ paddingRight: 2 }}
119
117
  >
120
118
  <For each={messagesWithQuotes()}>
121
119
  {(message, index) => {
@@ -128,7 +126,7 @@ export function MessageList() {
128
126
 
129
127
  const header = () => `${speaker} (${formatTime(message.timestamp)}) [✂️ ${message._quoteIndex}]:`;
130
128
 
131
- const displayContent = insertQuoteMarkers(buildMessageText(message), message._quotes);
129
+ const displayContent = insertQuoteMarkers(buildMessageText(message, humanDisplayName()), message._quotes);
132
130
 
133
131
  const showDivider = () => {
134
132
  const boundary = activeContextBoundary();
@@ -162,7 +160,7 @@ export function MessageList() {
162
160
  attributes={TextAttributes.BOLD}
163
161
  content={header()}
164
162
  />
165
- <box marginLeft={2}>
163
+ <box paddingLeft={2} flexGrow={1}>
166
164
  <markdown
167
165
  content={displayContent}
168
166
  syntaxStyle={solarizedDarkSyntax}
@@ -27,6 +27,7 @@ import { authCommand } from '../commands/auth';
27
27
  import { dedupeCommand } from "../commands/dedupe";
28
28
  import { roomCommand } from "../commands/room.js";
29
29
  import { activateCommand } from "../commands/activate.js";
30
+ import { reflectCommand } from "../commands/reflect.js";
30
31
  import { silenceCommand } from "../commands/silence.js";
31
32
  import { captureCommand } from "../commands/capture.js";
32
33
  import { openCYPEditor } from "../util/cyp-editor.js";
@@ -90,6 +91,7 @@ export function PromptInput() {
90
91
  registerCommand(resumeCommand);
91
92
  registerCommand(archiveCommand);
92
93
  registerCommand(unarchiveCommand);
94
+ registerCommand(reflectCommand);
93
95
 
94
96
  let textareaRef: TextareaRenderable | undefined;
95
97
 
@@ -164,7 +166,7 @@ export function PromptInput() {
164
166
  );
165
167
  const recalled = recallHumanRoomMessage();
166
168
  if (recalled) {
167
- const content = pendingMsg?.verbal_response ?? pendingMsg?.silence_reason ?? "";
169
+ const content = pendingMsg?.content ?? pendingMsg?.silence_reason ?? "";
168
170
  textareaRef?.setText(content);
169
171
  setInputText(content);
170
172
  textareaRef?.gotoBufferEnd();
@@ -7,12 +7,8 @@ import type { RoomMessage, Quote } from "../../../src/core/types.js";
7
7
  import { RoomMode } from "../../../src/core/types/enums.js";
8
8
  import { insertQuoteMarkers } from "../util/quote-utils.js";
9
9
 
10
- function getContent(msg: { content?: string; verbal_response?: string; action_response?: string }): string {
11
- if (msg.content) return msg.content;
12
- const parts: string[] = [];
13
- if (msg.action_response) parts.push(`_${msg.action_response}_`);
14
- if (msg.verbal_response) parts.push(msg.verbal_response);
15
- return parts.join('\n\n');
10
+ function getContent(msg: { content?: string }): string {
11
+ return msg.content ?? '';
16
12
  }
17
13
 
18
14
  interface RoomMessageWithQuotes extends RoomMessage {
@@ -147,6 +143,7 @@ export function RoomMessageList() {
147
143
  stickyScroll={true}
148
144
  stickyStart="bottom"
149
145
  viewportCulling={true}
146
+ wrapperOptions={{ paddingRight: 2 }}
150
147
  >
151
148
  <For each={displayMessagesWithQuotes()}>
152
149
  {(msg) => {
@@ -176,10 +173,10 @@ export function RoomMessageList() {
176
173
  attributes={TextAttributes.BOLD}
177
174
  content={header}
178
175
  />
179
- <box marginLeft={2} visible={isSilence}>
176
+ <box paddingLeft={2} flexGrow={1} visible={isSilence}>
180
177
  <text fg="#586e75" content={silenceText} />
181
178
  </box>
182
- <box marginLeft={2} visible={!isSilence}>
179
+ <box paddingLeft={2} flexGrow={1} visible={!isSilence}>
183
180
  <markdown
184
181
  content={normalContent}
185
182
  syntaxStyle={solarizedDarkSyntax}
@@ -25,11 +25,8 @@ export function Sidebar() {
25
25
  personas().filter(p => !p.is_archived)
26
26
  );
27
27
 
28
- // Memoize visible (non-archived) rooms sorted by last_activity desc
29
28
  const visibleRooms = createMemo(() =>
30
- rooms()
31
- .filter((r: RoomSummary) => !r.is_archived)
32
- .sort((a: RoomSummary, b: RoomSummary) => b.last_activity.localeCompare(a.last_activity))
29
+ rooms().filter((r: RoomSummary) => !r.is_archived)
33
30
  );
34
31
 
35
32
  const [highlightedPersona, setHighlightedPersona] = createSignal<string | null>(null);
@@ -79,7 +76,8 @@ export function Sidebar() {
79
76
  const name = displayName();
80
77
  const unread = persona.unread_count > 0 ? ` (${persona.unread_count} new)` : "";
81
78
  const paused = persona.is_paused ? " ⏸" : "";
82
- return `${prefix}${name}${unread}${paused}`;
79
+ const reflect = persona.has_pending_update ? " ✦" : "";
80
+ return `${prefix}${name}${unread}${paused}${reflect}`;
83
81
  };
84
82
 
85
83
  const textColor = () => {
@@ -133,22 +133,34 @@ export function StatusBar() {
133
133
  >
134
134
  <box flexGrow={1}>
135
135
  <Show when={notification()} fallback={
136
- <Show when={activeRoomId()} fallback={
137
- <text fg="#586e75">
138
- <Show when={getActiveDisplayName()} fallback="No persona selected">
139
- {getActiveDisplayName()}
136
+ <Show
137
+ when={(() => {
138
+ const id = activePersonaId();
139
+ return id ? personas().find(p => p.id === id)?.has_pending_update : false;
140
+ })()}
141
+ fallback={
142
+ <Show when={activeRoomId()} fallback={
143
+ <text fg="#586e75">
144
+ <Show when={getActiveDisplayName()} fallback="No persona selected">
145
+ {getActiveDisplayName()}
146
+ </Show>
147
+ </text>
148
+ }>
149
+ <Show when={allPersonasResponded() && humanRoomMessagePending()} fallback={
150
+ <text fg="#586e75">
151
+ {getRoomWaitingText()}
152
+ </text>
153
+ }>
154
+ <text fg="#586e75">
155
+ {activeRoom()?.display_name ?? ""}
156
+ </text>
157
+ </Show>
140
158
  </Show>
159
+ }
160
+ >
161
+ <text fg="#b58900">
162
+ {`✦ Reflection ready for ${getActiveDisplayName()} — /reflect to learn more`}
141
163
  </text>
142
- }>
143
- <Show when={allPersonasResponded() && humanRoomMessagePending()} fallback={
144
- <text fg="#586e75">
145
- {getRoomWaitingText()}
146
- </text>
147
- }>
148
- <text fg="#586e75">
149
- {activeRoom()?.display_name ?? ""}
150
- </text>
151
- </Show>
152
164
  </Show>
153
165
  }>
154
166
  <text fg={getNotificationColor()}>
@@ -178,7 +178,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
178
178
  }
179
179
  }
180
180
  // Navigate backward through sent-message history
181
- const history = messages().filter(m => m.role === "human").map(m => (m.content ?? m.verbal_response ?? ''));
181
+ const history = messages().filter(m => m.role === "human").map(m => (m.content ?? ''));
182
182
  if (history.length === 0) return;
183
183
  if (historyIndex === -1) {
184
184
  savedDraft = textareaRef.plainText;
@@ -206,7 +206,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
206
206
  textareaRef.gotoBufferEnd();
207
207
  } else {
208
208
  historyIndex -= 1;
209
- const history = messages().filter(m => m.role === "human").map(m => (m.content ?? m.verbal_response ?? ''));
209
+ const history = messages().filter(m => m.role === "human").map(m => (m.content ?? ''));
210
210
  const entry = history[history.length - 1 - historyIndex];
211
211
  textareaRef.setText(entry);
212
212
  textareaRef.gotoBufferEnd(); // cursor at end so next Down continues forward
@@ -7,12 +7,8 @@ interface ParsedBlock {
7
7
  chosen: boolean;
8
8
  }
9
9
 
10
- function getContent(m: { content?: string; verbal_response?: string; action_response?: string }): string {
11
- if (m.content) return m.content;
12
- const parts: string[] = [];
13
- if (m.action_response) parts.push(`_${m.action_response}_`);
14
- if (m.verbal_response) parts.push(m.verbal_response);
15
- return parts.join('\n\n');
10
+ function getContent(m: { content?: string }): string {
11
+ return m.content ?? '';
16
12
  }
17
13
 
18
14
  export function buildCYPEditorYAML(
@@ -12,12 +12,8 @@ interface EditableMessage {
12
12
  silence_reason?: string;
13
13
  }
14
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');
15
+ function getContent(m: { content?: string }): string {
16
+ return m.content ?? '';
21
17
  }
22
18
 
23
19
  export function contextToYAML(messages: Message[]): string {
@@ -214,15 +214,15 @@ export function personaToYAML(persona: PersonaEntity, allGroups?: string[], allT
214
214
  : persona.traits.map(({ name, description, sentiment, strength }) => ({ name, description, sentiment: sentiment ?? 0, strength: strength ?? 0.5 })),
215
215
  topics: useTopicPlaceholder
216
216
  ? [PLACEHOLDER_TOPIC]
217
- : persona.topics.map(({ name, perspective, approach, personal_stake, exposure_current, exposure_desired }) => ({
218
- name, perspective, approach, personal_stake, exposure_current, exposure_desired
217
+ : persona.topics.map(({ name, perspective, approach, personal_stake, sentiment, exposure_current, exposure_desired }) => ({
218
+ name, perspective, approach, personal_stake, sentiment: sentiment ?? 0, exposure_current, exposure_desired
219
219
  })),
220
220
  heartbeat_delay_ms: persona.heartbeat_delay_ms ? formatDuration(persona.heartbeat_delay_ms) : null,
221
221
  context_window_hours: persona.context_window_hours ?? null,
222
222
  is_paused: persona.is_paused || undefined,
223
223
  pause_until: persona.pause_until,
224
224
  is_static: persona.is_static || undefined,
225
- include_message_timestamps: persona.include_message_timestamps || undefined,
225
+ include_message_timestamps: persona.include_message_timestamps ?? false,
226
226
  tools: toolsMap,
227
227
  };
228
228