ei-tui 0.4.3 → 0.5.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 (102) hide show
  1. package/README.md +14 -0
  2. package/package.json +1 -1
  3. package/src/cli/README.md +17 -12
  4. package/src/cli/commands/personas.ts +12 -0
  5. package/src/cli/mcp.ts +2 -2
  6. package/src/cli/retrieval.ts +86 -8
  7. package/src/cli.ts +8 -5
  8. package/src/core/constants/seed-traits.ts +29 -0
  9. package/src/core/context-utils.ts +1 -0
  10. package/src/core/handlers/human-matching.ts +86 -56
  11. package/src/core/handlers/index.ts +5 -0
  12. package/src/core/handlers/persona-preview.ts +7 -0
  13. package/src/core/handlers/persona-topics.ts +3 -2
  14. package/src/core/handlers/rooms.ts +176 -0
  15. package/src/core/handlers/utils.ts +55 -3
  16. package/src/core/heartbeat-manager.ts +3 -1
  17. package/src/core/llm-client.ts +1 -1
  18. package/src/core/message-manager.ts +10 -8
  19. package/src/core/orchestrators/human-extraction.ts +15 -2
  20. package/src/core/orchestrators/index.ts +1 -0
  21. package/src/core/orchestrators/persona-generation.ts +4 -0
  22. package/src/core/orchestrators/persona-topics.ts +2 -1
  23. package/src/core/orchestrators/room-extraction.ts +318 -0
  24. package/src/core/persona-manager.ts +16 -5
  25. package/src/core/personas/opencode-agent.ts +12 -2
  26. package/src/core/processor.ts +520 -4
  27. package/src/core/prompt-context-builder.ts +89 -5
  28. package/src/core/queue-processor.ts +68 -8
  29. package/src/core/room-manager.ts +408 -0
  30. package/src/core/state/index.ts +1 -0
  31. package/src/core/state/personas.ts +12 -2
  32. package/src/core/state/queue.ts +2 -2
  33. package/src/core/state/rooms.ts +182 -0
  34. package/src/core/state-manager.ts +124 -2
  35. package/src/core/tool-manager.ts +1 -1
  36. package/src/core/tools/index.ts +15 -0
  37. package/src/core/types/data-items.ts +3 -1
  38. package/src/core/types/enums.ts +11 -0
  39. package/src/core/types/integrations.ts +10 -2
  40. package/src/core/types/llm.ts +3 -0
  41. package/src/core/types/rooms.ts +59 -0
  42. package/src/core/types.ts +1 -0
  43. package/src/core/utils/decay.ts +14 -8
  44. package/src/core/utils/exposure.ts +14 -0
  45. package/src/integrations/claude-code/importer.ts +23 -10
  46. package/src/integrations/cursor/importer.ts +22 -10
  47. package/src/integrations/opencode/importer.ts +30 -13
  48. package/src/prompts/ceremony/dedup.ts +2 -2
  49. package/src/prompts/generation/from-person.ts +85 -0
  50. package/src/prompts/generation/index.ts +2 -0
  51. package/src/prompts/generation/persona.ts +14 -10
  52. package/src/prompts/generation/seeds.ts +4 -29
  53. package/src/prompts/generation/types.ts +13 -0
  54. package/src/prompts/heartbeat/check.ts +1 -1
  55. package/src/prompts/heartbeat/ei.ts +4 -4
  56. package/src/prompts/heartbeat/types.ts +1 -0
  57. package/src/prompts/index.ts +15 -0
  58. package/src/prompts/message-utils.ts +2 -2
  59. package/src/prompts/persona/topics-match.ts +7 -6
  60. package/src/prompts/persona/topics-update.ts +8 -11
  61. package/src/prompts/persona/types.ts +2 -1
  62. package/src/prompts/response/index.ts +1 -1
  63. package/src/prompts/response/sections.ts +20 -8
  64. package/src/prompts/response/types.ts +6 -0
  65. package/src/prompts/room/index.ts +115 -0
  66. package/src/prompts/room/sections.ts +150 -0
  67. package/src/prompts/room/types.ts +93 -0
  68. package/tui/README.md +20 -0
  69. package/tui/src/app.tsx +3 -2
  70. package/tui/src/commands/activate.tsx +98 -0
  71. package/tui/src/commands/archive.tsx +54 -25
  72. package/tui/src/commands/capture.tsx +50 -0
  73. package/tui/src/commands/dedupe.tsx +2 -7
  74. package/tui/src/commands/delete.tsx +48 -0
  75. package/tui/src/commands/details.tsx +7 -0
  76. package/tui/src/commands/persona.tsx +271 -9
  77. package/tui/src/commands/room.tsx +261 -0
  78. package/tui/src/commands/silence.tsx +29 -0
  79. package/tui/src/components/ArchivedItemsOverlay.tsx +144 -0
  80. package/tui/src/components/ConfirmOverlay.tsx +6 -0
  81. package/tui/src/components/ConflictOverlay.tsx +6 -0
  82. package/tui/src/components/HelpOverlay.tsx +6 -1
  83. package/tui/src/components/LoadingOverlay.tsx +51 -0
  84. package/tui/src/components/MessageList.tsx +1 -18
  85. package/tui/src/components/PersonPickerOverlay.tsx +121 -0
  86. package/tui/src/components/PersonaListOverlay.tsx +6 -1
  87. package/tui/src/components/PromptInput.tsx +141 -8
  88. package/tui/src/components/ProviderListOverlay.tsx +5 -1
  89. package/tui/src/components/QuotesOverlay.tsx +5 -1
  90. package/tui/src/components/RoomMessageList.tsx +179 -0
  91. package/tui/src/components/Sidebar.tsx +54 -2
  92. package/tui/src/components/StatusBar.tsx +99 -8
  93. package/tui/src/components/ToolkitListOverlay.tsx +5 -1
  94. package/tui/src/components/WelcomeOverlay.tsx +6 -0
  95. package/tui/src/context/ei.tsx +252 -1
  96. package/tui/src/context/keyboard.tsx +48 -12
  97. package/tui/src/util/cyp-editor.tsx +152 -0
  98. package/tui/src/util/quote-utils.ts +19 -0
  99. package/tui/src/util/room-editor.tsx +164 -0
  100. package/tui/src/util/room-logic.ts +8 -0
  101. package/tui/src/util/room-parser.ts +70 -0
  102. package/tui/src/util/yaml-serializers.ts +151 -0
@@ -1,32 +1,37 @@
1
1
  import type { Command } from "./registry";
2
- import { PersonaListOverlay } from "../components/PersonaListOverlay";
2
+ import { ArchivedItemsOverlay } from "../components/ArchivedItemsOverlay";
3
3
 
4
4
  export const archiveCommand: Command = {
5
5
  name: "archive",
6
6
  aliases: [],
7
- description: "Archive a persona or list archived personas",
7
+ description: "Archive a persona or list archived personas and rooms",
8
8
  usage: "/archive [name]",
9
9
 
10
10
  async execute(args, ctx) {
11
11
  const allPersonas = ctx.ei.personas();
12
- const archived = allPersonas.filter(p => p.is_archived);
12
+ const archivedPersonas = allPersonas.filter(p => p.is_archived);
13
13
 
14
14
  if (args.length === 0) {
15
- if (archived.length === 0) {
16
- ctx.showNotification("No archived personas", "info");
15
+ const archivedRooms = ctx.ei.getArchivedRooms();
16
+ if (archivedPersonas.length === 0 && archivedRooms.length === 0) {
17
+ ctx.showNotification("No archived personas or rooms", "info");
17
18
  return;
18
19
  }
19
20
  ctx.showOverlay((hideOverlay, _hideForEditor) => (
20
- <PersonaListOverlay
21
- personas={archived}
22
- activePersonaId={null}
23
- title="Archived Personas (Enter to unarchive)"
24
- onSelect={async (personaId) => {
25
- const persona = archived.find(p => p.id === personaId);
21
+ <ArchivedItemsOverlay
22
+ personas={archivedPersonas}
23
+ rooms={archivedRooms}
24
+ onSelect={async (item) => {
26
25
  hideOverlay();
27
- await ctx.ei.unarchivePersona(personaId);
28
- ctx.ei.selectPersona(personaId);
29
- ctx.showNotification(`Unarchived and switched to ${persona?.display_name ?? personaId}`, "info");
26
+ if (item.kind === "persona") {
27
+ await ctx.ei.unarchivePersona(item.id);
28
+ ctx.ei.selectPersona(item.id);
29
+ ctx.showNotification(`Unarchived and switched to ${item.display_name}`, "info");
30
+ } else {
31
+ await ctx.ei.updateRoom(item.id, { is_archived: false });
32
+ ctx.ei.selectRoom(item.id);
33
+ ctx.showNotification(`Room "${item.display_name}" unarchived`, "info");
34
+ }
30
35
  }}
31
36
  onDismiss={hideOverlay}
32
37
  />
@@ -38,7 +43,18 @@ export const archiveCommand: Command = {
38
43
  const personaId = await ctx.ei.resolvePersonaName(nameOrAlias);
39
44
 
40
45
  if (!personaId) {
41
- ctx.showNotification(`Persona '${nameOrAlias}' not found`, "error");
46
+ const roomId = ctx.ei.resolveRoomName(nameOrAlias);
47
+ if (roomId) {
48
+ const room = ctx.ei.getRoom(roomId);
49
+ if (room?.is_archived) {
50
+ ctx.showNotification(`Room "${room.display_name}" is already archived`, "warn");
51
+ return;
52
+ }
53
+ await ctx.ei.archiveRoom(roomId);
54
+ ctx.showNotification(`Room "${room?.display_name ?? nameOrAlias}" archived`, "info");
55
+ return;
56
+ }
57
+ ctx.showNotification(`Persona or room '${nameOrAlias}' not found`, "error");
42
58
  return;
43
59
  }
44
60
 
@@ -62,7 +78,7 @@ export const archiveCommand: Command = {
62
78
  export const unarchiveCommand: Command = {
63
79
  name: "unarchive",
64
80
  aliases: [],
65
- description: "Unarchive a persona and switch to it",
81
+ description: "Unarchive a persona or room and switch to it",
66
82
  usage: "/unarchive <name>",
67
83
 
68
84
  async execute(args, ctx) {
@@ -74,20 +90,33 @@ export const unarchiveCommand: Command = {
74
90
  const nameOrAlias = args.join(" ");
75
91
  const personaId = await ctx.ei.resolvePersonaName(nameOrAlias);
76
92
 
77
- if (!personaId) {
78
- ctx.showNotification(`Archived persona '${nameOrAlias}' not found`, "error");
93
+ if (personaId) {
94
+ const persona = ctx.ei.personas().find(p => p.id === personaId);
95
+
96
+ if (!persona?.is_archived) {
97
+ ctx.showNotification(`'${persona?.display_name ?? nameOrAlias}' is not archived`, "warn");
98
+ return;
99
+ }
100
+
101
+ await ctx.ei.unarchivePersona(personaId);
102
+ ctx.ei.selectPersona(personaId);
103
+ ctx.showNotification(`Unarchived and switched to ${persona.display_name}`, "info");
79
104
  return;
80
105
  }
81
106
 
82
- const persona = ctx.ei.personas().find(p => p.id === personaId);
83
-
84
- if (!persona?.is_archived) {
85
- ctx.showNotification(`'${persona?.display_name ?? nameOrAlias}' is not archived`, "warn");
107
+ const roomId = ctx.ei.resolveRoomName(nameOrAlias);
108
+ if (roomId) {
109
+ const room = ctx.ei.getRoom(roomId);
110
+ if (!room?.is_archived) {
111
+ ctx.showNotification(`Room '${room?.display_name ?? nameOrAlias}' is not archived`, "warn");
112
+ return;
113
+ }
114
+ await ctx.ei.updateRoom(roomId, { is_archived: false });
115
+ ctx.ei.selectRoom(roomId);
116
+ ctx.showNotification(`Room "${room?.display_name ?? nameOrAlias}" unarchived`, "info");
86
117
  return;
87
118
  }
88
119
 
89
- await ctx.ei.unarchivePersona(personaId);
90
- ctx.ei.selectPersona(personaId);
91
- ctx.showNotification(`Unarchived and switched to ${persona.display_name}`, "info");
120
+ ctx.showNotification(`Archived persona or room '${nameOrAlias}' not found`, "error");
92
121
  }
93
122
  };
@@ -0,0 +1,50 @@
1
+ import type { Command } from "./registry";
2
+
3
+ export const captureCommand: Command = {
4
+ name: "capture",
5
+ aliases: [],
6
+ description: "Trigger extraction on current chat",
7
+ usage: "/capture | /capture opencode",
8
+
9
+ async execute(args, ctx) {
10
+ if (args.length === 0) {
11
+ if (ctx.ei.activeRoomId()) {
12
+ ctx.ei.captureRoom();
13
+ ctx.showNotification("Extraction queued for room", "info");
14
+ } else {
15
+ ctx.ei.capturePersona();
16
+ ctx.showNotification("Extraction queued", "info");
17
+ }
18
+ return;
19
+ }
20
+
21
+ const integrationMap: Record<string, "opencode" | "claudeCode" | "cursor"> = {
22
+ opencode: "opencode",
23
+ claudecode: "claudeCode",
24
+ cursor: "cursor",
25
+ };
26
+ const integrationKey = integrationMap[args[0].toLowerCase()];
27
+ if (integrationKey) {
28
+ const human = await ctx.ei.getHuman();
29
+ const intSettings = human.settings?.[integrationKey];
30
+ if (!intSettings?.integration) {
31
+ ctx.showNotification(`${args[0]} integration not enabled. Enable in /settings.`, "warn");
32
+ return;
33
+ }
34
+ // Reset last_sync to epoch to force immediate scan on next processor tick
35
+ await ctx.ei.updateHuman({
36
+ settings: {
37
+ ...human.settings,
38
+ [integrationKey]: {
39
+ ...intSettings,
40
+ last_sync: new Date(0).toISOString(),
41
+ },
42
+ },
43
+ });
44
+ ctx.showNotification(`${args[0]} scan will start on next cycle`, "info");
45
+ return;
46
+ }
47
+
48
+ ctx.showNotification("Named capture not yet supported. Use /room or /persona to switch first.", "warn");
49
+ },
50
+ };
@@ -11,7 +11,7 @@ function buildDedupeYAML(type: DedupeType, terms: string[], entities: Array<Topi
11
11
  `# /dedupe ${type} ${terms.map(t => t.includes(" ") ? `"${t}"` : t).join(" ")}`,
12
12
  `# Terms: ${termDisplay}`,
13
13
  `# Found ${entities.length} match${entities.length === 1 ? "" : "es"}. DELETE blocks for entries to EXCLUDE from the merge.`,
14
- `# Keep at least 2. Save to confirm, :q to cancel (Vim tip: :cq quits with error — same effect, but now you know it exists).`,
14
+ `# Keep at least 2. Save or quit to confirm :cq to cancel.`,
15
15
  ``,
16
16
  ].join("\n");
17
17
 
@@ -129,12 +129,7 @@ export const dedupeCommand: Command = {
129
129
  return;
130
130
  }
131
131
 
132
- if (result.content === null) {
133
- ctx.showNotification("No changes — dedupe cancelled", "info");
134
- return;
135
- }
136
-
137
- const keptIds = parseDedupeYAML(result.content);
132
+ const keptIds = parseDedupeYAML(result.content ?? yamlContent);
138
133
 
139
134
  if (keptIds.length < 2) {
140
135
  ctx.showNotification("Need at least 2 entries to merge — dedupe cancelled", "error");
@@ -9,6 +9,30 @@ export const deleteCommand: Command = {
9
9
  usage: "/delete [name]",
10
10
 
11
11
  async execute(args, ctx) {
12
+ if (ctx.ei.activeRoomId() && args.length === 0) {
13
+ const roomId = ctx.ei.activeRoomId()!;
14
+ const room = ctx.ei.getRoom(roomId);
15
+ const displayName = room?.display_name ?? roomId;
16
+
17
+ const confirmed = await new Promise<boolean>((resolve) => {
18
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
19
+ <ConfirmOverlay
20
+ message={`Archive room "${displayName}"?\nThe room will be archived and removed from the list.`}
21
+ onConfirm={() => { hideOverlay(); resolve(true); }}
22
+ onCancel={() => { hideOverlay(); resolve(false); }}
23
+ />
24
+ ), ctx.renderer);
25
+ });
26
+
27
+ if (confirmed) {
28
+ await ctx.ei.archiveRoom(roomId);
29
+ ctx.showNotification(`Room "${displayName}" archived`, "info");
30
+ } else {
31
+ ctx.showNotification("Cancelled", "info");
32
+ }
33
+ return;
34
+ }
35
+
12
36
  const allPersonas = ctx.ei.personas();
13
37
  const deletable = allPersonas.filter(p => p.id !== ctx.ei.activePersonaId());
14
38
 
@@ -56,6 +80,30 @@ export const deleteCommand: Command = {
56
80
  const personaId = await ctx.ei.resolvePersonaName(nameOrAlias);
57
81
 
58
82
  if (!personaId) {
83
+ const roomId = ctx.ei.resolveRoomName(nameOrAlias);
84
+ if (roomId) {
85
+ const room = ctx.ei.getRoom(roomId);
86
+ const displayName = room?.display_name ?? roomId;
87
+
88
+ const confirmed = await new Promise<boolean>((resolve) => {
89
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
90
+ <ConfirmOverlay
91
+ message={`Archive room "${displayName}"?\nThe room will be archived and removed from the list.`}
92
+ onConfirm={() => { hideOverlay(); resolve(true); }}
93
+ onCancel={() => { hideOverlay(); resolve(false); }}
94
+ />
95
+ ), ctx.renderer);
96
+ });
97
+
98
+ if (confirmed) {
99
+ await ctx.ei.archiveRoom(roomId);
100
+ ctx.showNotification(`Room "${displayName}" archived`, "info");
101
+ } else {
102
+ ctx.showNotification("Cancelled", "info");
103
+ }
104
+ return;
105
+ }
106
+
59
107
  ctx.showNotification(`Persona '${nameOrAlias}' not found`, "error");
60
108
  return;
61
109
  }
@@ -1,5 +1,6 @@
1
1
  import type { Command } from "./registry.js";
2
2
  import { openPersonaEditor } from "../util/persona-editor.js";
3
+ import { openRoomEditor } from "../util/room-editor.js";
3
4
 
4
5
  export const detailsCommand: Command = {
5
6
  name: "details",
@@ -8,6 +9,12 @@ export const detailsCommand: Command = {
8
9
  usage: "/details [persona] - Edit specified or current persona",
9
10
 
10
11
  async execute(args, ctx) {
12
+ if (args.length === 0 && ctx.ei.activeRoomId()) {
13
+ const roomId = ctx.ei.activeRoomId()!;
14
+ await openRoomEditor({ roomId, ctx });
15
+ return;
16
+ }
17
+
11
18
  let personaId: string | null;
12
19
 
13
20
  if (args.length > 0) {
@@ -1,17 +1,27 @@
1
1
  import type { Command } from "./registry";
2
2
  import { isReservedPersonaName } from "../../../src/core/types.js";
3
3
  import { PersonaListOverlay } from "../components/PersonaListOverlay";
4
- import { createPersonaViaEditor } from "../util/persona-editor.js";
4
+ import { LoadingOverlay } from "../components/LoadingOverlay.js";
5
+ import { PersonPickerOverlay } from "../components/PersonPickerOverlay.js";
6
+ import type { PersonPickerItem } from "../components/PersonPickerOverlay.js";
7
+ import { ConfirmOverlay } from "../components/ConfirmOverlay.js";
8
+ import { spawnEditor } from "../util/editor.js";
9
+ import {
10
+ descriptionEntryToYAML,
11
+ descriptionFromYAML,
12
+ personaPreviewToYAML,
13
+ personaPreviewFromYAML,
14
+ } from "../util/yaml-serializers.js";
5
15
 
6
16
  export const personaCommand: Command = {
7
17
  name: "persona",
8
18
  aliases: ["p"],
9
- description: "Switch persona, list all, or create new",
10
- usage: "/persona [name] | /persona new <name>",
11
-
19
+ description: "Switch persona, list all, create new, or update from person",
20
+ usage: "/persona [name] | /persona new <name> | /persona update <personaName> <personName>",
21
+
12
22
  async execute(args, ctx) {
13
23
  const unarchived = ctx.ei.personas().filter(p => !p.is_archived);
14
-
24
+
15
25
  if (args.length === 0) {
16
26
  ctx.showOverlay((hideOverlay, _hideForEditor) => (
17
27
  <PersonaListOverlay
@@ -28,7 +38,7 @@ export const personaCommand: Command = {
28
38
  ), ctx.renderer);
29
39
  return;
30
40
  }
31
-
41
+
32
42
  if (args[0].toLowerCase() === "new") {
33
43
  if (args.length < 2) {
34
44
  ctx.showNotification("Usage: /p new <name>", "error");
@@ -39,14 +49,266 @@ export const personaCommand: Command = {
39
49
  ctx.showNotification(`Cannot use reserved name "${personaName}"`, "error");
40
50
  return;
41
51
  }
42
- await createPersonaViaEditor({ personaName, ctx });
52
+
53
+ // Step 1: description editor
54
+ const descResult = await spawnEditor({
55
+ initialContent: descriptionEntryToYAML(personaName),
56
+ filename: `${personaName}-description.yaml`,
57
+ renderer: ctx.renderer,
58
+ });
59
+
60
+ if (descResult.aborted || descResult.content === null) {
61
+ ctx.showNotification("Cancelled", "info");
62
+ return;
63
+ }
64
+
65
+ let parsed: { description: string; relationship?: string };
66
+ try {
67
+ parsed = descriptionFromYAML(descResult.content);
68
+ } catch (e) {
69
+ ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
70
+ return;
71
+ }
72
+
73
+ if (!parsed.description) {
74
+ ctx.showNotification("No description provided", "error");
75
+ return;
76
+ }
77
+
78
+ // Step 2: generate preview with loading overlay
79
+ let dismissed = false;
80
+ const overlayCallbacks = { hideOverlay: null as (() => void) | null, hideForEditor: null as (() => void) | null };
81
+
82
+ const previewPromise = new Promise<import('../../../src/prompts/generation/types.js').PersonaGenerationResult | null>((resolve) => {
83
+ ctx.showOverlay((hideOverlay, hideForEditor) => {
84
+ overlayCallbacks.hideOverlay = hideOverlay;
85
+ overlayCallbacks.hideForEditor = hideForEditor;
86
+ return (
87
+ <LoadingOverlay
88
+ message={`Generating persona preview for "${personaName}"...`}
89
+ onCancel={() => {
90
+ dismissed = true;
91
+ hideOverlay();
92
+ resolve(null);
93
+ }}
94
+ />
95
+ );
96
+ }, ctx.renderer);
97
+
98
+ ctx.ei.generatePersonaPreview(personaName, parsed.description, parsed.relationship)
99
+ .then(result => {
100
+ if (!dismissed && overlayCallbacks.hideOverlay) {
101
+ resolve(result);
102
+ }
103
+ })
104
+ .catch(e => {
105
+ if (!dismissed && overlayCallbacks.hideOverlay) {
106
+ overlayCallbacks.hideOverlay();
107
+ ctx.showNotification(`Generation failed: ${e instanceof Error ? e.message : String(e)}`, "error");
108
+ resolve(null);
109
+ }
110
+ });
111
+ });
112
+
113
+ const preview = await previewPromise;
114
+ if (!preview) return;
115
+
116
+ overlayCallbacks.hideForEditor?.();
117
+
118
+ // Step 3: review editor
119
+ const previewYAML = personaPreviewToYAML(preview, personaName);
120
+ const reviewResult = await spawnEditor({
121
+ initialContent: previewYAML,
122
+ filename: `${personaName}-preview.yaml`,
123
+ renderer: ctx.renderer,
124
+ });
125
+
126
+ if (reviewResult.aborted) {
127
+ ctx.showNotification("Cancelled", "info");
128
+ return;
129
+ }
130
+
131
+ let previewParsed: ReturnType<typeof personaPreviewFromYAML>;
132
+ try {
133
+ previewParsed = personaPreviewFromYAML(reviewResult.content ?? previewYAML);
134
+ } catch (e) {
135
+ ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
136
+ return;
137
+ }
138
+
139
+ // Step 4: create
140
+ const personaId = await ctx.ei.createPersona({
141
+ name: personaName,
142
+ ...previewParsed,
143
+ });
144
+ await ctx.ei.refreshPersonas();
145
+ ctx.ei.selectPersona(personaId);
146
+ ctx.showNotification(`Created ${personaName}`, "info");
43
147
  return;
44
148
  }
45
-
149
+
150
+ if (args[0].toLowerCase() === "update") {
151
+ if (args.length < 3) {
152
+ ctx.showNotification("Usage: /p update <personaName> <personName>", "error");
153
+ return;
154
+ }
155
+ const personaName = args[1];
156
+ const personName = args.slice(2).join(" ");
157
+
158
+ // Step 0: resolve persona (offer to create if not found)
159
+ let personaId = await ctx.ei.resolvePersonaName(personaName);
160
+ if (!personaId) {
161
+ const shouldCreate = await new Promise<boolean>(resolve => {
162
+ ctx.showOverlay((hideOverlay) => (
163
+ <ConfirmOverlay
164
+ message={`No persona named "${personaName}". Create one?`}
165
+ onConfirm={() => { hideOverlay(); resolve(true); }}
166
+ onCancel={() => { hideOverlay(); resolve(false); }}
167
+ />
168
+ ), ctx.renderer);
169
+ });
170
+ if (!shouldCreate) return;
171
+ personaId = await ctx.ei.createPersona({ name: personaName });
172
+ await ctx.ei.refreshPersonas();
173
+ }
174
+ const persona = await ctx.ei.getPersona(personaId);
175
+ if (!persona) {
176
+ ctx.showNotification(`Could not load persona "${personaName}"`, "error");
177
+ return;
178
+ }
179
+
180
+ // Step 1: find matching people
181
+ const human = await ctx.ei.getHuman();
182
+ const matches = (human.people ?? []).filter(p =>
183
+ p.name.toLowerCase().includes(personName.toLowerCase())
184
+ );
185
+
186
+ if (matches.length === 0) {
187
+ ctx.showNotification(`No person named "${personName}" in your data`, "error");
188
+ return;
189
+ }
190
+
191
+ // Step 2: disambiguation if multiple matches
192
+ let selectedPerson: typeof matches[0];
193
+ if (matches.length > 1) {
194
+ const people: PersonPickerItem[] = matches.map(p => ({
195
+ id: p.id,
196
+ name: p.name,
197
+ relationship: p.relationship,
198
+ description: p.description,
199
+ }));
200
+
201
+ const choice = await new Promise<typeof matches[0] | null>((resolve) => {
202
+ ctx.showOverlay((hideOverlay, _hideForEditor) => (
203
+ <PersonPickerOverlay
204
+ title={`Multiple matches for "${personName}"`}
205
+ people={people}
206
+ onSelect={(item) => {
207
+ hideOverlay();
208
+ const found = matches.find(m => m.id === item.id);
209
+ resolve(found ?? null);
210
+ }}
211
+ onDismiss={() => {
212
+ hideOverlay();
213
+ resolve(null);
214
+ }}
215
+ />
216
+ ), ctx.renderer);
217
+ });
218
+
219
+ if (!choice) return;
220
+ selectedPerson = choice;
221
+ } else {
222
+ selectedPerson = matches[0];
223
+ }
224
+
225
+ // Step 3: generate preview with loading overlay
226
+ let dismissed = false;
227
+ const overlayCallbacks2 = { hideOverlay: null as (() => void) | null, hideForEditor: null as (() => void) | null };
228
+
229
+ const previewPromise = new Promise<import('../../../src/prompts/generation/types.js').PersonaGenerationResult | null>((resolve) => {
230
+ ctx.showOverlay((hideOverlay, hideForEditor) => {
231
+ overlayCallbacks2.hideOverlay = hideOverlay;
232
+ overlayCallbacks2.hideForEditor = hideForEditor;
233
+ return (
234
+ <LoadingOverlay
235
+ message={`Generating preview for "${persona.display_name}" from "${selectedPerson.name}"...`}
236
+ onCancel={() => {
237
+ dismissed = true;
238
+ hideOverlay();
239
+ resolve(null);
240
+ }}
241
+ />
242
+ );
243
+ }, ctx.renderer);
244
+
245
+ ctx.ei.generatePersonaPreview(
246
+ persona.display_name,
247
+ selectedPerson.description ?? '',
248
+ selectedPerson.relationship,
249
+ personaId
250
+ )
251
+ .then(result => {
252
+ if (!dismissed && overlayCallbacks2.hideOverlay) {
253
+ resolve(result);
254
+ }
255
+ })
256
+ .catch(e => {
257
+ if (!dismissed && overlayCallbacks2.hideOverlay) {
258
+ overlayCallbacks2.hideOverlay();
259
+ ctx.showNotification(`Generation failed: ${e instanceof Error ? e.message : String(e)}`, "error");
260
+ resolve(null);
261
+ }
262
+ });
263
+ });
264
+
265
+ const preview = await previewPromise;
266
+ if (!preview) return;
267
+
268
+ overlayCallbacks2.hideForEditor?.();
269
+
270
+ // Step 4: review editor
271
+ const updatePreviewYAML = personaPreviewToYAML(
272
+ preview,
273
+ persona.display_name,
274
+ selectedPerson.name,
275
+ preview.previous_long_description
276
+ );
277
+ const reviewResult = await spawnEditor({
278
+ initialContent: updatePreviewYAML,
279
+ filename: `${personaId}-update-preview.yaml`,
280
+ renderer: ctx.renderer,
281
+ });
282
+
283
+ if (reviewResult.aborted) {
284
+ ctx.showNotification("Cancelled", "info");
285
+ return;
286
+ }
287
+
288
+ // Step 5: parse + update
289
+ let previewParsed: ReturnType<typeof personaPreviewFromYAML>;
290
+ try {
291
+ previewParsed = personaPreviewFromYAML(reviewResult.content ?? updatePreviewYAML);
292
+ } catch (e) {
293
+ ctx.showNotification(`Parse error: ${e instanceof Error ? e.message : String(e)}`, "error");
294
+ return;
295
+ }
296
+
297
+ await ctx.ei.updatePersona(personaId, {
298
+ long_description: previewParsed.long_description,
299
+ short_description: previewParsed.short_description,
300
+ ...(previewParsed.aliases ? { aliases: previewParsed.aliases } : {}),
301
+ traits: previewParsed.traits,
302
+ topics: previewParsed.topics,
303
+ });
304
+ ctx.showNotification(`Updated ${persona.display_name}`, "info");
305
+ return;
306
+ }
307
+
46
308
  // User typed a name - resolve it to ID, then switch
47
309
  const nameOrAlias = args.join(" ");
48
310
  const personaId = await ctx.ei.resolvePersonaName(nameOrAlias);
49
-
311
+
50
312
  if (personaId) {
51
313
  const persona = unarchived.find(p => p.id === personaId);
52
314
  ctx.ei.selectPersona(personaId);