ei-tui 0.5.4 → 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.
Files changed (86) hide show
  1. package/package.json +1 -1
  2. package/src/core/constants/built-in-identifier-types.ts +24 -0
  3. package/src/core/embedding-service.ts +24 -1
  4. package/src/core/handlers/dedup.ts +34 -4
  5. package/src/core/handlers/heartbeat.ts +16 -0
  6. package/src/core/handlers/human-extraction.ts +201 -7
  7. package/src/core/handlers/human-matching.ts +71 -22
  8. package/src/core/handlers/index.ts +52 -14
  9. package/src/core/handlers/persona-generation.ts +2 -0
  10. package/src/core/handlers/persona-response.ts +37 -22
  11. package/src/core/handlers/persona-topics.ts +35 -271
  12. package/src/core/handlers/rewrite.ts +3 -0
  13. package/src/core/handlers/rooms.ts +41 -20
  14. package/src/core/handlers/utils.ts +10 -8
  15. package/src/core/heartbeat-manager.ts +60 -2
  16. package/src/core/llm-client.ts +1 -1
  17. package/src/core/message-manager.ts +3 -2
  18. package/src/core/orchestrators/ceremony.ts +54 -144
  19. package/src/core/orchestrators/dedup-phase.ts +0 -199
  20. package/src/core/orchestrators/extraction-chunker.ts +8 -3
  21. package/src/core/orchestrators/human-extraction.ts +37 -85
  22. package/src/core/orchestrators/index.ts +4 -8
  23. package/src/core/orchestrators/person-migration.ts +55 -0
  24. package/src/core/orchestrators/persona-topics.ts +64 -89
  25. package/src/core/orchestrators/room-extraction.ts +34 -0
  26. package/src/core/persona-manager.ts +21 -2
  27. package/src/core/personas/opencode-agent.ts +1 -0
  28. package/src/core/processor.ts +51 -14
  29. package/src/core/prompt-context-builder.ts +38 -5
  30. package/src/core/queue-processor.ts +4 -2
  31. package/src/core/room-manager.ts +6 -7
  32. package/src/core/state/human.ts +6 -0
  33. package/src/core/state/personas.ts +35 -10
  34. package/src/core/state/rooms.ts +21 -0
  35. package/src/core/state-manager.ts +61 -0
  36. package/src/core/types/data-items.ts +12 -0
  37. package/src/core/types/entities.ts +3 -0
  38. package/src/core/types/enums.ts +2 -7
  39. package/src/core/types/llm.ts +2 -0
  40. package/src/core/types/rooms.ts +2 -0
  41. package/src/core/utils/identifier-utils.ts +19 -0
  42. package/src/core/utils/index.ts +2 -1
  43. package/src/core/utils/levenshtein.ts +18 -0
  44. package/src/integrations/claude-code/importer.ts +1 -0
  45. package/src/integrations/cursor/importer.ts +1 -0
  46. package/src/prompts/ceremony/index.ts +1 -0
  47. package/src/prompts/ceremony/person-migration.ts +77 -0
  48. package/src/prompts/ceremony/rewrite.ts +1 -1
  49. package/src/prompts/ceremony/user-dedup.ts +15 -1
  50. package/src/prompts/heartbeat/check.ts +28 -12
  51. package/src/prompts/heartbeat/ei.ts +2 -0
  52. package/src/prompts/heartbeat/types.ts +12 -0
  53. package/src/prompts/human/index.ts +0 -2
  54. package/src/prompts/human/person-scan.ts +58 -14
  55. package/src/prompts/human/person-update.ts +171 -96
  56. package/src/prompts/human/topic-update.ts +1 -1
  57. package/src/prompts/human/types.ts +5 -1
  58. package/src/prompts/index.ts +3 -10
  59. package/src/prompts/message-utils.ts +9 -23
  60. package/src/prompts/persona/index.ts +3 -10
  61. package/src/prompts/persona/topics-rate.ts +95 -0
  62. package/src/prompts/persona/types.ts +8 -48
  63. package/src/prompts/response/index.ts +3 -7
  64. package/src/prompts/response/sections.ts +7 -57
  65. package/src/prompts/room/index.ts +1 -1
  66. package/src/prompts/room/sections.ts +8 -31
  67. package/tui/src/commands/me.tsx +14 -7
  68. package/tui/src/commands/persona.tsx +120 -83
  69. package/tui/src/components/MessageList.tsx +9 -4
  70. package/tui/src/components/RoomMessageList.tsx +10 -5
  71. package/tui/src/context/keyboard.tsx +2 -2
  72. package/tui/src/util/cyp-editor.tsx +13 -8
  73. package/tui/src/util/yaml-context.ts +66 -0
  74. package/tui/src/util/yaml-human.ts +274 -0
  75. package/tui/src/util/yaml-persona.ts +479 -0
  76. package/tui/src/util/yaml-provider.ts +215 -0
  77. package/tui/src/util/yaml-queue.ts +81 -0
  78. package/tui/src/util/yaml-quotes.ts +46 -0
  79. package/tui/src/util/yaml-serializers.ts +9 -1417
  80. package/tui/src/util/yaml-settings.ts +223 -0
  81. package/tui/src/util/yaml-shared.ts +32 -0
  82. package/tui/src/util/yaml-toolkit.ts +55 -0
  83. package/src/prompts/human/person-match.ts +0 -65
  84. package/src/prompts/persona/topics-match.ts +0 -70
  85. package/src/prompts/persona/topics-scan.ts +0 -98
  86. package/src/prompts/persona/topics-update.ts +0 -154
@@ -0,0 +1,223 @@
1
+ import YAML from "yaml";
2
+ import type {
3
+ HumanSettings,
4
+ CeremonyConfig,
5
+ OpenCodeSettings,
6
+ ProviderAccount,
7
+ } from "../../../src/core/types.js";
8
+ import type { ClaudeCodeSettings } from "../../../src/integrations/claude-code/types.js";
9
+ import type { CursorSettings } from "../../../src/integrations/cursor/types.js";
10
+ import { modelGuidToDisplay, displayToModelGuid } from "./yaml-shared.js";
11
+
12
+ interface EditableSettingsData {
13
+ default_model?: string | null;
14
+ oneshot_model?: string | null;
15
+ rewrite_model?: string | null;
16
+ time_mode?: "24h" | "12h" | "local" | "utc" | null;
17
+ name_display?: string | null;
18
+ default_heartbeat_ms?: number | null;
19
+ default_context_window_hours?: number | null;
20
+ message_min_count?: number | null;
21
+ message_max_age_days?: number | null;
22
+ ceremony?: {
23
+ time: string;
24
+ decay_rate?: number | null;
25
+ explore_threshold?: number | null;
26
+ dedup_threshold?: number | null;
27
+ event_window_hours?: number | null;
28
+ };
29
+ opencode?: {
30
+ integration?: boolean | null;
31
+ polling_interval_ms?: number | null;
32
+ last_sync?: string | null;
33
+ extraction_point?: string | null;
34
+ extraction_model?: string | null;
35
+ };
36
+ claudeCode?: {
37
+ integration?: boolean | null;
38
+ polling_interval_ms?: number | null;
39
+ last_sync?: string | null;
40
+ extraction_point?: string | null;
41
+ extraction_model?: string | null;
42
+ };
43
+ cursor?: {
44
+ integration?: boolean | null;
45
+ polling_interval_ms?: number | null;
46
+ last_sync?: string | null;
47
+ extraction_point?: string | null;
48
+ };
49
+ backup?: {
50
+ enabled?: boolean | null;
51
+ max_backups?: number | null;
52
+ interval_ms?: number | null;
53
+ };
54
+ }
55
+
56
+ export function settingsToYAML(settings: HumanSettings | undefined, accounts: ProviderAccount[]): string {
57
+ const guidToDisplay = (guid: string | undefined | null): string | null => {
58
+ if (!guid) return null;
59
+ return modelGuidToDisplay(guid, accounts);
60
+ };
61
+
62
+ const data: EditableSettingsData = {
63
+ default_model: guidToDisplay(settings?.default_model),
64
+ oneshot_model: guidToDisplay(settings?.oneshot_model),
65
+ rewrite_model: guidToDisplay(settings?.rewrite_model),
66
+ time_mode: settings?.time_mode ?? null,
67
+ name_display: settings?.name_display ?? null,
68
+ default_heartbeat_ms: settings?.default_heartbeat_ms ?? 1800000,
69
+ default_context_window_hours: settings?.default_context_window_hours ?? 8,
70
+ message_min_count: settings?.message_min_count ?? 200,
71
+ message_max_age_days: settings?.message_max_age_days ?? 14,
72
+ ceremony: {
73
+ time: settings?.ceremony?.time ?? "09:00",
74
+ decay_rate: settings?.ceremony?.decay_rate ?? null,
75
+ explore_threshold: settings?.ceremony?.explore_threshold ?? null,
76
+ dedup_threshold: settings?.ceremony?.dedup_threshold ?? null,
77
+ event_window_hours: settings?.ceremony?.event_window_hours ?? null,
78
+ },
79
+ opencode: {
80
+ integration: settings?.opencode?.integration ?? false,
81
+ polling_interval_ms: settings?.opencode?.polling_interval_ms ?? 60000,
82
+ extraction_model: guidToDisplay(settings?.opencode?.extraction_model) ?? 'default',
83
+ last_sync: settings?.opencode?.last_sync ?? null,
84
+ extraction_point: settings?.opencode?.extraction_point ?? null,
85
+ },
86
+ claudeCode: {
87
+ integration: settings?.claudeCode?.integration ?? false,
88
+ polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 60000,
89
+ extraction_model: guidToDisplay(settings?.claudeCode?.extraction_model) ?? 'default',
90
+ last_sync: settings?.claudeCode?.last_sync ?? null,
91
+ extraction_point: settings?.claudeCode?.extraction_point ?? null,
92
+ },
93
+ cursor: {
94
+ integration: settings?.cursor?.integration ?? false,
95
+ polling_interval_ms: settings?.cursor?.polling_interval_ms ?? 60000,
96
+ last_sync: settings?.cursor?.last_sync ?? null,
97
+ extraction_point: settings?.cursor?.extraction_point ?? null,
98
+ },
99
+ backup: {
100
+ enabled: settings?.backup?.enabled ?? false,
101
+ max_backups: settings?.backup?.max_backups ?? 24,
102
+ interval_ms: settings?.backup?.interval_ms ?? 3600000,
103
+ },
104
+ };
105
+
106
+ return YAML.stringify(data, {
107
+ lineWidth: 0,
108
+ })
109
+ .replace(/^(\s+)(last_sync: .+)$/mg, '$1# [read-only] $2')
110
+ .replace(/^(\s+)(extraction_point: .+)$/mg, '$1# [read-only] $2')
111
+ .replace(/^(\s+)(extraction_model: .+)$/mg, '$1$2 # e.g. Anthropic:claude-haiku-4-5');
112
+ }
113
+
114
+ export function settingsFromYAML(yamlContent: string, original: HumanSettings | undefined, accounts: ProviderAccount[]): HumanSettings {
115
+ const data = YAML.parse(yamlContent) as EditableSettingsData;
116
+
117
+ const nullToUndefined = <T>(value: T | null | undefined): T | undefined =>
118
+ value === null ? undefined : value;
119
+
120
+ const displayToGuid = (display: string | null | undefined): string | undefined => {
121
+ if (!display || display === 'default') return undefined;
122
+ return displayToModelGuid(display, accounts) ?? display;
123
+ };
124
+
125
+ let ceremony: CeremonyConfig | undefined;
126
+ if (data.ceremony) {
127
+ ceremony = {
128
+ time: data.ceremony.time,
129
+ decay_rate: nullToUndefined(data.ceremony.decay_rate),
130
+ explore_threshold: nullToUndefined(data.ceremony.explore_threshold),
131
+ dedup_threshold: nullToUndefined(data.ceremony.dedup_threshold),
132
+ event_window_hours: nullToUndefined(data.ceremony.event_window_hours),
133
+ last_ceremony: original?.ceremony?.last_ceremony,
134
+ };
135
+ }
136
+
137
+ let opencode: OpenCodeSettings | undefined;
138
+ if (data.opencode) {
139
+ opencode = {
140
+ integration: nullToUndefined(data.opencode.integration),
141
+ polling_interval_ms: nullToUndefined(data.opencode.polling_interval_ms),
142
+ last_sync: original?.opencode?.last_sync,
143
+ extraction_point: original?.opencode?.extraction_point,
144
+ processed_sessions: original?.opencode?.processed_sessions,
145
+ extraction_model: displayToGuid(data.opencode.extraction_model),
146
+ };
147
+ }
148
+
149
+ let claudeCode: ClaudeCodeSettings | undefined;
150
+ if (data.claudeCode) {
151
+ claudeCode = {
152
+ integration: nullToUndefined(data.claudeCode.integration),
153
+ polling_interval_ms: nullToUndefined(data.claudeCode.polling_interval_ms),
154
+ last_sync: original?.claudeCode?.last_sync,
155
+ extraction_point: original?.claudeCode?.extraction_point,
156
+ processed_sessions: original?.claudeCode?.processed_sessions,
157
+ extraction_model: displayToGuid(data.claudeCode.extraction_model),
158
+ };
159
+ }
160
+
161
+ let cursor: CursorSettings | undefined;
162
+ if (data.cursor) {
163
+ cursor = {
164
+ integration: nullToUndefined(data.cursor.integration),
165
+ polling_interval_ms: nullToUndefined(data.cursor.polling_interval_ms),
166
+ last_sync: original?.cursor?.last_sync,
167
+ extraction_point: original?.cursor?.extraction_point,
168
+ processed_sessions: original?.cursor?.processed_sessions,
169
+ };
170
+ }
171
+
172
+ let backup: import('../../../src/core/types.js').BackupConfig | undefined;
173
+ if (data.backup) {
174
+ backup = {
175
+ enabled: nullToUndefined(data.backup.enabled),
176
+ max_backups: nullToUndefined(data.backup.max_backups),
177
+ interval_ms: nullToUndefined(data.backup.interval_ms),
178
+ last_backup: original?.backup?.last_backup,
179
+ };
180
+ }
181
+
182
+ return {
183
+ ...original,
184
+ default_model: displayToGuid(data.default_model),
185
+ oneshot_model: displayToGuid(data.oneshot_model),
186
+ rewrite_model: displayToGuid(data.rewrite_model),
187
+ time_mode: nullToUndefined(data.time_mode),
188
+ name_display: nullToUndefined(data.name_display),
189
+ default_heartbeat_ms: nullToUndefined(data.default_heartbeat_ms),
190
+ default_context_window_hours: nullToUndefined(data.default_context_window_hours),
191
+ message_min_count: nullToUndefined(data.message_min_count),
192
+ message_max_age_days: nullToUndefined(data.message_max_age_days),
193
+ ceremony,
194
+ opencode,
195
+ claudeCode,
196
+ cursor,
197
+ backup,
198
+ };
199
+ }
200
+
201
+ export function validateModelProvider(
202
+ modelSpec: string | undefined,
203
+ accounts: ProviderAccount[]
204
+ ): string | undefined {
205
+ if (!modelSpec) return undefined;
206
+
207
+ const colonIdx = modelSpec.indexOf(":");
208
+ const providerPart = colonIdx >= 0 ? modelSpec.substring(0, colonIdx) : modelSpec;
209
+ const modelPart = colonIdx >= 0 ? modelSpec.substring(colonIdx + 1) : undefined;
210
+
211
+ const match = accounts.find(a => a.name.toLowerCase() === providerPart.toLowerCase());
212
+
213
+ if (!match) {
214
+ const available = accounts.map(a => a.name).join(", ");
215
+ throw new Error(
216
+ available
217
+ ? `No provider named "${providerPart}". Available: ${available}`
218
+ : `No provider named "${providerPart}". Create one with /provider new`
219
+ );
220
+ }
221
+
222
+ return modelPart ? `${match.name}:${modelPart}` : match.name;
223
+ }
@@ -0,0 +1,32 @@
1
+ // =============================================================================
2
+ // GUID <-> DISPLAY NAME HELPERS
3
+ // =============================================================================
4
+
5
+ import type { ProviderAccount } from "../../../src/core/types.js";
6
+
7
+ /**
8
+ * Convert a model GUID to "ProviderName:modelName" display string.
9
+ * Falls back to the raw GUID if the model is not found.
10
+ */
11
+ export function modelGuidToDisplay(guid: string, accounts: ProviderAccount[]): string {
12
+ for (const account of accounts) {
13
+ const model = (account.models ?? []).find(m => m.id === guid);
14
+ if (model) return `${account.name}:${model.name}`;
15
+ }
16
+ return guid; // fallback: return raw GUID if not found
17
+ }
18
+
19
+ /**
20
+ * Resolve "ProviderName:modelName" display string back to a model GUID.
21
+ * Returns undefined if no matching provider+model is found.
22
+ * Handles colons in model names by treating everything after the first colon as the model name.
23
+ */
24
+ export function displayToModelGuid(display: string, accounts: ProviderAccount[]): string | undefined {
25
+ const colonIdx = display.indexOf(':');
26
+ if (colonIdx < 0) return undefined;
27
+ const providerName = display.substring(0, colonIdx);
28
+ const modelName = display.substring(colonIdx + 1);
29
+ const account = accounts.find(a => a.name === providerName);
30
+ const model = (account?.models ?? []).find(m => m.name === modelName);
31
+ return model?.id;
32
+ }
@@ -0,0 +1,55 @@
1
+ import YAML from "yaml";
2
+ import type { ToolProvider, ToolDefinition } from "../../../src/core/types.js";
3
+
4
+ interface EditableToolkitData {
5
+ display_name: string;
6
+ enabled: boolean;
7
+ config: Record<string, string>;
8
+ tools?: Record<string, boolean>;
9
+ }
10
+
11
+ export function toolkitToYAML(provider: ToolProvider, tools: ToolDefinition[]): string {
12
+ const toolsMap = tools.length > 0
13
+ ? Object.fromEntries(tools.map(t => [t.display_name, t.enabled]))
14
+ : undefined;
15
+ if (provider.builtin) {
16
+ return YAML.stringify({ enabled: provider.enabled, tools: toolsMap }, { lineWidth: 0 });
17
+ }
18
+ const data: EditableToolkitData = {
19
+ display_name: provider.display_name,
20
+ enabled: provider.enabled,
21
+ config: { ...provider.config },
22
+ tools: toolsMap,
23
+ };
24
+ return YAML.stringify(data, { lineWidth: 0 });
25
+ }
26
+
27
+ export interface ToolkitYAMLResult {
28
+ updates: Partial<Omit<ToolProvider, 'id' | 'created_at'>>;
29
+ toolUpdates: Array<{ id: string; enabled: boolean }>;
30
+ }
31
+
32
+ export function toolkitFromYAML(yamlContent: string, original: ToolProvider, tools: ToolDefinition[]): ToolkitYAMLResult {
33
+ const data = YAML.parse(yamlContent) as EditableToolkitData;
34
+
35
+ if (!data.display_name) {
36
+ if (!original.display_name) throw new Error("display_name is required");
37
+ data.display_name = original.display_name;
38
+ }
39
+
40
+ const updates: Partial<Omit<ToolProvider, 'id' | 'created_at'>> = {
41
+ display_name: data.display_name,
42
+ enabled: data.enabled ?? original.enabled,
43
+ config: data.config ?? {},
44
+ };
45
+
46
+ const toolUpdates: Array<{ id: string; enabled: boolean }> = [];
47
+ if (data.tools) {
48
+ for (const [displayName, enabled] of Object.entries(data.tools)) {
49
+ const tool = tools.find(t => t.display_name === displayName);
50
+ if (tool) toolUpdates.push({ id: tool.id, enabled: Boolean(enabled) });
51
+ }
52
+ }
53
+
54
+ return { updates, toolUpdates };
55
+ }
@@ -1,65 +0,0 @@
1
- import type { PromptOutput } from "./types.js";
2
-
3
- export interface PersonMatchPromptData {
4
- candidate_name: string;
5
- candidate_description: string;
6
- candidate_relationship: string;
7
- existing_people: Array<{
8
- id: string;
9
- name: string;
10
- description: string;
11
- relationship?: string;
12
- }>;
13
- }
14
-
15
- export function buildPersonMatchPrompt(data: PersonMatchPromptData): PromptOutput {
16
- if (!data.candidate_name) {
17
- throw new Error("buildPersonMatchPrompt: candidate_name is required");
18
- }
19
-
20
- const system = `# Task
21
-
22
- You are checking if a PERSON already exists in our database.
23
-
24
- ## Matching Rules
25
-
26
- 1. **Exact match**: Same person by name or clear identity → return their ID
27
- 2. **Similar match**: Same person referred to differently ("Mom" vs "Carol", "my boss" vs "Trumble") → return their ID
28
- 3. **No match**: Genuinely new person → return "new"
29
-
30
- Be conservative. If you're unsure, return "new" — a duplicate is worse than a gap.
31
-
32
- # Existing People
33
-
34
- \`\`\`json
35
- ${JSON.stringify(data.existing_people, null, 2)}
36
- \`\`\`
37
-
38
- # Response Format
39
-
40
- Return ONLY the ID of the matching entry, or "new".
41
-
42
- \`\`\`json
43
- {
44
- "matched_guid": "uuid-of-matching-entry" | "new"
45
- }
46
- \`\`\`
47
-
48
- **Return JSON only.**`;
49
-
50
- const user = `# Candidate Person
51
-
52
- Name: ${data.candidate_name}
53
- Description: ${data.candidate_description}
54
- Relationship: ${data.candidate_relationship}
55
-
56
- Find the best match in existing people, or return "new" if this is a genuinely new person.
57
-
58
- \`\`\`json
59
- {
60
- "matched_guid": "..." | "new"
61
- }
62
- \`\`\``;
63
-
64
- return { system, user };
65
- }
@@ -1,70 +0,0 @@
1
- import type { PersonaTopicMatchPromptData, PromptOutput } from "./types.js";
2
- import type { PersonaTopic } from "../../core/types.js";
3
-
4
- function formatExistingTopics(topics: PersonaTopic[]): string {
5
- if (topics.length === 0) return "[]";
6
-
7
- return JSON.stringify(topics.map(t => ({
8
- id: t.id,
9
- name: t.name,
10
- perspective: t.perspective.substring(0, 100) + (t.perspective.length > 100 ? '...' : ''),
11
- })), null, 2);
12
- }
13
-
14
- export function buildPersonaTopicMatchPrompt(data: PersonaTopicMatchPromptData): PromptOutput {
15
- if (!data.persona_name || !data.candidate) {
16
- throw new Error("buildPersonaTopicMatchPrompt: persona_name and candidate are required");
17
- }
18
-
19
- const personaName = data.persona_name;
20
-
21
- const system = `# Task
22
-
23
- You are checking if a topic candidate already exists in ${personaName}'s topic list.
24
-
25
- # Matching Rules
26
-
27
- 1. **Exact match**: Same topic name → action "match", return its ID
28
- 2. **Similar match**: Clearly the same topic with different wording → action "match", return its ID
29
- - "Steam Deck" and "Steam Deck Modding" → MATCH (same core topic)
30
- - "Cooking" and "Italian Cooking" → consider MATCH if the specific is part of the general
31
- 3. **No match**: Genuinely different topic → action "create"
32
- 4. **Skip**: Trivial, off-topic, or too vague to be a meaningful persona topic → action "skip"
33
-
34
- # Existing Topics
35
-
36
- \`\`\`json
37
- ${formatExistingTopics(data.existing_topics)}
38
- \`\`\`
39
-
40
- # Response Format
41
-
42
- \`\`\`json
43
- {
44
- "action": "match" | "create" | "skip",
45
- "matched_id": "uuid-of-matching-topic" | null,
46
- "reason": "brief explanation"
47
- }
48
- \`\`\`
49
-
50
- **Return JSON only.**`;
51
-
52
- const user = `# Candidate Topic
53
-
54
- Name: ${data.candidate.name}
55
- Message Count: ${data.candidate.message_count}
56
- Sentiment Signal: ${data.candidate.sentiment_signal}
57
-
58
- Find the best match in ${personaName}'s existing topics, or decide if this should be created or skipped.
59
-
60
- **Return JSON:**
61
- \`\`\`json
62
- {
63
- "action": "match" | "create" | "skip",
64
- "matched_id": "..." | null,
65
- "reason": "..."
66
- }
67
- \`\`\``;
68
-
69
- return { system, user };
70
- }
@@ -1,98 +0,0 @@
1
- import type { PersonaTopicScanPromptData, PromptOutput } from "./types.js";
2
- import { formatMessagesAsPlaceholders } from "../message-utils.js";
3
-
4
- export function buildPersonaTopicScanPrompt(data: PersonaTopicScanPromptData): PromptOutput {
5
- if (!data.persona_name) {
6
- throw new Error("buildPersonaTopicScanPrompt: persona_name is required");
7
- }
8
-
9
- const personaName = data.persona_name;
10
-
11
- const system = `# Task
12
-
13
- You are scanning a conversation to quickly identify TOPICS that ${personaName} actively ENGAGED with.
14
-
15
- Your ONLY job is to spot topics and count how many messages touched them. Do NOT analyze deeply. Just detect and flag.
16
-
17
- # What Counts as Engagement
18
-
19
- **Engagement means ${personaName}:**
20
- - Discussed with knowledge or shared opinions
21
- - Asked follow-up questions showing interest
22
- - Offered insights or elaborated on the topic
23
- - Showed genuine interest through detailed responses
24
-
25
- **NOT engagement:**
26
- - Topic mentioned only by the human (without ${personaName} responding to it)
27
- - Mentioned in passing without real discussion
28
- - Generic conversational acknowledgments
29
-
30
- # What is a TOPIC
31
-
32
- A meaningful subject or concept that ${personaName} cares about or discusses:
33
- - Hobbies and interests (gaming, cooking, hiking)
34
- - Current concerns (work stress, health)
35
- - Media they consume (books, shows, podcasts)
36
- - Ongoing projects or situations
37
- - Abstract ideas they're exploring
38
-
39
- **NOT TOPICS** (these are traits):
40
- - Communication style ("talks formally")
41
- - Personality patterns ("optimistic", "skeptical")
42
-
43
- # Response Format
44
-
45
- Return a list of topics with message counts.
46
-
47
- \`\`\`json
48
- {
49
- "topics": [
50
- {
51
- "name": "Steam Deck Modding",
52
- "message_count": 5,
53
- "sentiment_signal": 0.7
54
- }
55
- ]
56
- }
57
- \`\`\`
58
-
59
- - \`name\`: Short identifier for the topic
60
- - \`message_count\`: How many messages in the conversation touched this topic (CRITICAL for filtering noise)
61
- - \`sentiment_signal\`: Quick read on how ${personaName} feels about it (-1.0 to 1.0)
62
-
63
- **CRITICAL**: ONLY analyze "Most Recent Messages". Earlier conversation is context only.
64
-
65
- **Return JSON only.**`;
66
-
67
- const earlierSection = data.messages_context.length > 0
68
- ? `## Earlier Conversation (context only)
69
- ${formatMessagesAsPlaceholders(data.messages_context, personaName)}
70
-
71
- `
72
- : '';
73
-
74
- const recentSection = `## Most Recent Messages (analyze these)
75
- ${formatMessagesAsPlaceholders(data.messages_analyze, personaName)}`;
76
-
77
- const user = `# Conversation
78
- ${earlierSection}${recentSection}
79
-
80
- ---
81
-
82
- Scan the "Most Recent Messages" for topics ${personaName} actively engaged with.
83
-
84
- **Return JSON:**
85
- \`\`\`json
86
- {
87
- "topics": [
88
- {
89
- "name": "topic name",
90
- "message_count": 3,
91
- "sentiment_signal": 0.5
92
- }
93
- ]
94
- }
95
- \`\`\``;
96
-
97
- return { system, user };
98
- }
@@ -1,154 +0,0 @@
1
- import type { PersonaTopicUpdatePromptData, PromptOutput } from "./types.js";
2
- import type { PersonaTopic, PersonaTrait } from "../../core/types.js";
3
- import { formatMessagesAsPlaceholders } from "../message-utils.js";
4
-
5
- function formatTraitsForPrompt(traits: PersonaTrait[]): string {
6
- if (traits.length === 0) return "(No traits defined)";
7
- return traits.map(t => `- ${t.name}: ${t.description}`).join('\n');
8
- }
9
-
10
- function formatExistingTopic(topic: PersonaTopic | undefined): string {
11
- if (!topic) return "**NEW TOPIC** - Creating from scratch";
12
-
13
- return `**EXISTING TOPIC** - Updating:
14
- \`\`\`json
15
- ${JSON.stringify({
16
- name: topic.name,
17
- perspective: topic.perspective,
18
- approach: topic.approach,
19
- personal_stake: topic.personal_stake,
20
- sentiment: topic.sentiment,
21
- exposure_current: topic.exposure_current,
22
- exposure_desired: topic.exposure_desired,
23
- }, null, 2)}
24
- \`\`\``;
25
- }
26
-
27
- export function buildPersonaTopicUpdatePrompt(data: PersonaTopicUpdatePromptData): PromptOutput {
28
- if (!data.persona_name || !data.candidate) {
29
- throw new Error("buildPersonaTopicUpdatePrompt: persona_name and candidate are required");
30
- }
31
-
32
- const personaName = data.persona_name;
33
- const isNewTopic = !data.existing_topic;
34
-
35
- const system = `# Task
36
-
37
- You are generating or updating a PersonaTopic for ${personaName}.
38
-
39
- ${isNewTopic ? `This is a NEW topic - create all fields from scratch based on the conversation.` : `This is an EXISTING topic - update fields based on new conversation evidence.`}
40
-
41
- # Persona Context
42
-
43
- **Name**: ${personaName}
44
- ${data.short_description ? `**Description**: ${data.short_description}` : ''}
45
- ${data.long_description ? `**Background**: ${data.long_description}` : ''}
46
-
47
- **Personality Traits**:
48
- ${formatTraitsForPrompt(data.traits)}
49
-
50
- # Current Topic State
51
-
52
- ${formatExistingTopic(data.existing_topic)}
53
-
54
- # Field Definitions
55
-
56
- ## name
57
- Short identifier for the topic. Only change for clarification.
58
-
59
- ## perspective
60
- ${personaName}'s view or opinion on this topic. What do they think about it?
61
-
62
- **ALWAYS populate this field.** Use conversation content + persona traits to infer their view.
63
-
64
- Example: "The Shire represents everything worth fighting for - simple pleasures, good food, and the bonds of community."
65
-
66
- ## approach
67
- How ${personaName} prefers to engage with this topic. Their style of discussion.
68
-
69
- **Only populate if there's a clear pattern** in how they discuss this topic. Leave empty string if unclear.
70
-
71
- Example: "Frodo speaks of the Shire with wistful longing, often comparing other places to it unfavorably."
72
-
73
- ## personal_stake
74
- Why this topic matters to ${personaName} personally. Their connection to it.
75
-
76
- **Only populate if there's evidence** of personal connection. Leave empty string if unclear.
77
-
78
- Example: "As a hobbit who left home, the Shire is both memory and motivation."
79
-
80
- ## sentiment
81
- How ${personaName} feels about this topic. Scale: -1.0 (hate) to 1.0 (love).
82
-
83
- ## exposure_impact
84
- How much this topic was discussed in the recent messages. Choose one:
85
- - "high" — Major topic, multiple substantive exchanges
86
- - "medium" — Meaningful mention, discussed with some depth
87
- - "low" — Brief or passing mention
88
- - "none" — Barely mentioned, tangential at best
89
-
90
- ## exposure_desired
91
- How much ${personaName} wants to discuss this topic. Scale: 0.0 to 1.0.
92
-
93
- **RARELY change** for existing topics. Only adjust if there's explicit preference signal.
94
-
95
- **For new topics**: Infer from how enthusiastically ${personaName} engaged.
96
-
97
- # Critical Instructions
98
-
99
- 1. ONLY analyze "Most Recent Messages" - earlier messages are context only
100
- 2. Do NOT invent details not supported by the conversation
101
- 3. Do NOT apply flowery or poetic language - be factual
102
- 4. If a field cannot be determined, use empty string (for text) or preserve existing value (for numbers)
103
-
104
- **Return JSON:**
105
- \`\`\`json
106
- {
107
- "name": "Topic Name",
108
- "perspective": "Their view on this topic",
109
- "approach": "",
110
- "personal_stake": "",
111
- "sentiment": 0.5,
112
- "exposure_impact": "medium",
113
- "exposure_desired": 0.5
114
- }
115
- \`\`\``;
116
-
117
- const earlierSection = data.messages_context.length > 0
118
- ? `## Earlier Conversation (context only)
119
- ${formatMessagesAsPlaceholders(data.messages_context, personaName)}
120
-
121
- `
122
- : '';
123
-
124
- const recentSection = `## Most Recent Messages (analyze these)
125
- ${formatMessagesAsPlaceholders(data.messages_analyze, personaName)}`;
126
-
127
- const user = `# Conversation
128
- ${earlierSection}${recentSection}
129
-
130
- ---
131
-
132
- # Topic Candidate
133
-
134
- Name: ${data.candidate.name}
135
- Message Count: ${data.candidate.message_count}
136
- Sentiment Signal: ${data.candidate.sentiment_signal}
137
-
138
- ${isNewTopic ? 'Create' : 'Update'} the PersonaTopic based on how ${personaName} engaged with this topic.
139
-
140
- **Return JSON:**
141
- \`\`\`json
142
- {
143
- "name": "...",
144
- "perspective": "...",
145
- "approach": "...",
146
- "personal_stake": "...",
147
- "sentiment": 0.5,
148
- "exposure_impact": "medium",
149
- "exposure_desired": 0.5
150
- }
151
- \`\`\``;
152
-
153
- return { system, user };
154
- }