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,18 @@
1
+ export function levenshtein(a: string, b: string): number {
2
+ const m = a.length, n = b.length;
3
+ const dp: number[][] = Array.from({ length: m + 1 }, (_, i) =>
4
+ Array.from({ length: n + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0))
5
+ );
6
+ for (let i = 1; i <= m; i++) {
7
+ for (let j = 1; j <= n; j++) {
8
+ dp[i][j] = a[i-1] === b[j-1]
9
+ ? dp[i-1][j-1]
10
+ : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]);
11
+ }
12
+ }
13
+ return dp[m][n];
14
+ }
15
+
16
+ export function normalizeForMatch(s: string): string {
17
+ return s.toLowerCase().trim().replace(/[^\w\s]/g, '');
18
+ }
@@ -143,6 +143,7 @@ function ensureSessionTopic(
143
143
  persona_groups: CLAUDE_CODE_TOPIC_GROUPS,
144
144
  learned_by: stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME)?.id ?? undefined,
145
145
  last_updated: new Date().toISOString(),
146
+ learned_on: new Date().toISOString(),
146
147
  };
147
148
 
148
149
  stateManager.human_topic_upsert(newTopic);
@@ -127,6 +127,7 @@ function ensureSessionTopic(
127
127
  persona_groups: CURSOR_TOPIC_GROUPS,
128
128
  learned_by: stateManager.persona_getByName(CURSOR_PERSONA_NAME)?.id ?? undefined,
129
129
  last_updated: new Date().toISOString(),
130
+ learned_on: new Date().toISOString(),
130
131
  };
131
132
 
132
133
  stateManager.human_topic_upsert(newTopic);
@@ -4,6 +4,7 @@ export { buildDescriptionCheckPrompt } from "./description-check.js";
4
4
  export { buildRewriteScanPrompt, buildRewritePrompt } from "./rewrite.js";
5
5
  export { buildDedupPrompt } from "./dedup.js";
6
6
  export { buildUserDedupPrompt } from "./user-dedup.js";
7
+ export { buildPersonMigrationPrompt, type PersonMigrationPromptData } from "./person-migration.js";
7
8
  export type {
8
9
  PersonaExpirePromptData,
9
10
  PersonaExpireResult,
@@ -0,0 +1,77 @@
1
+ import type { Person } from "../../core/types/data-items.js";
2
+ import type { PromptOutput } from "../persona/types.js";
3
+
4
+ export interface PersonMigrationPromptData {
5
+ person: Pick<Person, "name" | "description" | "relationship">;
6
+ }
7
+
8
+ export function buildPersonMigrationPrompt(data: PersonMigrationPromptData): PromptOutput {
9
+ const { person } = data;
10
+
11
+ const system = `You are extracting identifiers for a Person record in a personal knowledge system.
12
+
13
+ A Person record has a \`name\` and \`description\` that describe who they are. Your job is to extract ALL identifiers for this person — every name, handle, alias, or platform ID that refers to them.
14
+
15
+ ## CRITICAL RULES
16
+
17
+ 1. The \`name\` field value MUST appear in identifiers — never lose it.
18
+ 2. If \`name\` contains a space (e.g. "Jeremy Scherer"), create BOTH a \`Full Name\` identifier AND a \`First Name\` identifier for the given name.
19
+ 3. Mark exactly one identifier as \`is_primary: true\` — the most natural display name.
20
+ 4. Check \`read_memory\` for additional context before finalizing.
21
+ 5. Return ONLY a JSON object with an \`identifiers\` array. No other text.
22
+ 6. "none" is never valid — every person has at least one identifier (their name).
23
+
24
+ ## DESCRIPTION PRE-PROCESSING
25
+
26
+ If the description begins with a JSON block (e.g. \`{"identifiers": [...]}\` or \`[{...}]\`), those are pre-seeded identifiers from a prior migration step:
27
+ - Parse them out and include them in your output
28
+ - Normalize their \`type\` values to Title Case (e.g. \`nickname\` → \`Nickname\`, \`full_name\` → \`Full Name\`)
29
+ - Treat the remainder of the description (after the JSON block) as the actual description text for \`read_memory\` context
30
+
31
+ ## IDENTIFIER TYPES
32
+
33
+ Use Title Case for all types. Built-in types:
34
+
35
+ | Type | Meaning |
36
+ |------|---------|
37
+ | Full Name | Legal or full birth name |
38
+ | First Name | Given/first name only |
39
+ | Nickname | Informal name, diminutive, pet name |
40
+ | Email | Email address |
41
+ | GitHub | GitHub username |
42
+ | Discord | Discord username |
43
+ | Roblox | Roblox username |
44
+ | Reddit | Reddit username |
45
+ | Twitter | Twitter/X handle |
46
+ | FF14 | Final Fantasy XIV character name |
47
+ | Relationship | How the user addresses this person: Dad, Pop, Sis, etc. |
48
+ | Ei Persona | Ei persona UUID (only if explicitly in the data) |
49
+
50
+ Any string is valid as a type — users define their own (e.g. \`Slack-ASU\`, \`Slack-RnP\`, \`sehimu_thinara\`). Use the exact casing the user would recognize.
51
+
52
+ ## RESPONSE FORMAT
53
+
54
+ \`\`\`json
55
+ {
56
+ "identifiers": [
57
+ { "type": "Nickname", "value": "Flare", "is_primary": true },
58
+ { "type": "Full Name", "value": "Jeremy Scherer" },
59
+ { "type": "First Name", "value": "Jeremy" },
60
+ { "type": "GitHub", "value": "Flare576" },
61
+ { "type": "Slack-RnP", "value": "jeremy.scherer" }
62
+ ]
63
+ }
64
+ \`\`\``;
65
+
66
+ const user = `Extract identifiers for this Person record.
67
+
68
+ Name: ${person.name}
69
+ Relationship: ${person.relationship ?? "unknown"}
70
+ Description: ${person.description ?? "(none)"}
71
+
72
+ First, call read_memory to search for any additional context about "${person.name}". Then return the complete identifiers array.
73
+
74
+ Return JSON only.`;
75
+
76
+ return { system, user };
77
+ }
@@ -74,7 +74,7 @@ ${buildNewExamples()}
74
74
 
75
75
  Rules:
76
76
  - The original record (id: "${data.item.id}") MUST appear in "existing", slimmed down.
77
- - Descriptions should be concise ideally under 300 characters, never over 500.
77
+ - Descriptions should be concise: ${data.itemType === 'topic' ? 'ideally under 300 characters, never over 500' : 'ideally under 600 characters, never over 1000'}.
78
78
  - Preserve sentiment, strength, confidence, and other numeric values from the source record where applicable.
79
79
  - "type" must be one of: "topic", "person".
80
80
  - Topics MUST include "category" — one of: Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project, Event. For Event topics, the description should be a narrative account of a specific moment, not a general summary.
@@ -94,7 +94,21 @@ function stripEmbedding<T extends { embedding?: unknown }>(item: T): Omit<T, "em
94
94
  function buildRecordFormatHint(itemType: string): string {
95
95
  switch (itemType) {
96
96
  case "person":
97
- return `Person fields: id, type, name, description, sentiment (-1 to 1), relationship, exposure_current (0-1), exposure_desired (0-1), learned_by (optional), last_changed_by (optional)`;
97
+ return `Person fields: id, type, name, identifiers (array of {type, value, is_primary?}), description, sentiment (-1 to 1), relationship, exposure_current (0-1), exposure_desired (0-1), learned_by (optional), last_changed_by (optional).
98
+
99
+ When merging two Person records, combine ALL identifiers from both records into a single deduplicated list (by value). Mark exactly one as is_primary.
100
+
101
+ Example merged person output:
102
+ {
103
+ "identifiers": [
104
+ { "type": "nickname", "value": "Flare", "is_primary": true },
105
+ { "type": "full_name", "value": "Jeremy Scherer" },
106
+ { "type": "github", "value": "Flare576" }
107
+ ],
108
+ "description": "merged description...",
109
+ "sentiment": 0.5,
110
+ "relationship": "Friend"
111
+ }`;
98
112
  case "topic":
99
113
  return `Topic fields: id, type, name, description, sentiment (-1 to 1), category, exposure_current (0-1), exposure_desired (0-1), learned_by (optional), last_changed_by (optional)`;
100
114
  default:
@@ -8,6 +8,7 @@
8
8
  import type { HeartbeatCheckPromptData, PromptOutput } from "./types.js";
9
9
  import { type Message, type Topic, type Person } from "../../core/types.js";
10
10
  import { formatMessagesAsPlaceholders, getMessageDisplayText } from "../message-utils.js";
11
+ import { getMessageContent } from "../../core/handlers/utils.js";
11
12
  function formatTopicsWithGaps(topics: Topic[]): string {
12
13
  if (topics.length === 0) return "(No topics with engagement gaps)";
13
14
 
@@ -38,8 +39,7 @@ function formatPeopleWithGaps(people: Person[]): string {
38
39
  function isConversationalMessage(m: Message): boolean {
39
40
  if (m.role !== 'system') return false;
40
41
  if (m.silence_reason !== undefined) return false;
41
- // Action-only: has action but no verbal response
42
- if (!m.verbal_response) return false;
42
+ if (!getMessageContent(m)) return false;
43
43
  return true;
44
44
  }
45
45
 
@@ -124,6 +124,22 @@ ${formatPeopleWithGaps(data.human.people)}`;
124
124
 
125
125
  **Quality over quantity** - Only reach out if you have something real to say.`;
126
126
 
127
+ const driftFragment = data.drift_context
128
+ ? `## Identity Reflection
129
+
130
+ The human's notes about you have recently evolved. Compare these:
131
+
132
+ **How the human currently describes you:**
133
+ ${data.drift_context.people_description}
134
+
135
+ **Your current definition:**
136
+ ${data.drift_context.persona_description}
137
+
138
+ If you feel these describe meaningfully different versions of you, you may want to gently surface this — in your own way, in your own time. If they seem the same to you, no need to mention it.
139
+
140
+ If you do choose to address this, include \`"mentioned_reflection": true\` in your response.`
141
+ : '';
142
+
127
143
  const outputFragment = `## Response Format
128
144
 
129
145
  Call the \`submit_heartbeat_check\` tool with your decision. If the tool is unavailable, return JSON:
@@ -132,7 +148,8 @@ Call the \`submit_heartbeat_check\` tool with your decision. If the tool is unav
132
148
  {
133
149
  "should_respond": true,
134
150
  "topic": "the specific topic you want to discuss",
135
- "message": "Your actual message to them (if should_respond is true)"
151
+ "message": "Your actual message to them (if should_respond is true)"${data.drift_context ? `,
152
+ "mentioned_reflection": true` : ''}
136
153
  }
137
154
  \`\`\`
138
155
 
@@ -143,15 +160,14 @@ If you decide NOT to reach out:
143
160
  }
144
161
  \`\`\``;
145
162
 
146
- const system = `${roleFragment}
147
-
148
- ${contextFragment}
149
-
150
- ${opportunitiesFragment}
151
-
152
- ${guidelinesFragment}
153
-
154
- ${outputFragment}`;
163
+ const system = [
164
+ roleFragment,
165
+ contextFragment,
166
+ opportunitiesFragment,
167
+ guidelinesFragment,
168
+ driftFragment,
169
+ outputFragment,
170
+ ].filter(Boolean).join('\n\n');
155
171
 
156
172
  const historySection = `## Recent Conversation History
157
173
 
@@ -28,6 +28,8 @@ function formatItem(item: EiHeartbeatItem): string {
28
28
  const desc = item.short_description ? ` — ${item.short_description}` : "";
29
29
  return `- **${item.id}** Inactive Persona: ${item.name}${desc} (${item.days_inactive} days inactive)`;
30
30
  }
31
+ default:
32
+ return '';
31
33
  }
32
34
  }
33
35
 
@@ -28,6 +28,10 @@ export interface HeartbeatCheckPromptData {
28
28
  };
29
29
  recent_history: Message[]; // Last N messages for context
30
30
  inactive_days: number; // Days since last activity
31
+ drift_context?: {
32
+ people_description: string;
33
+ persona_description: string;
34
+ };
31
35
  }
32
36
 
33
37
  /**
@@ -37,6 +41,7 @@ export interface HeartbeatCheckResult {
37
41
  should_respond: boolean;
38
42
  topic?: string;
39
43
  message?: string;
44
+ mentioned_reflection?: boolean;
40
45
  }
41
46
 
42
47
  // =============================================================================
@@ -79,6 +84,13 @@ export type EiHeartbeatItem =
79
84
  name: string;
80
85
  short_description?: string;
81
86
  days_inactive: number;
87
+ }
88
+ | {
89
+ id: string;
90
+ type: "New Person";
91
+ name: string;
92
+ description: string;
93
+ quote?: string;
82
94
  };
83
95
 
84
96
  /**
@@ -4,14 +4,12 @@ export { buildHumanTopicScanPrompt } from "./topic-scan.js";
4
4
  export { buildHumanPersonScanPrompt } from "./person-scan.js";
5
5
  export { buildTopicMatchPrompt } from "./topic-match.js";
6
6
  export { buildTopicUpdatePrompt } from "./topic-update.js";
7
- export { buildPersonMatchPrompt } from "./person-match.js";
8
7
  export { buildPersonUpdatePrompt } from "./person-update.js";
9
8
  export { buildEventScanPrompt } from "./event-scan.js";
10
9
  export type { EventScanPromptData } from "./event-scan.js";
11
10
 
12
11
  export type { TopicMatchPromptData } from "./topic-match.js";
13
12
  export type { TopicUpdatePromptData } from "./topic-update.js";
14
- export type { PersonMatchPromptData } from "./person-match.js";
15
13
  export type { PersonUpdatePromptData } from "./person-update.js";
16
14
 
17
15
  export type {
@@ -1,12 +1,35 @@
1
- import type { PersonScanPromptData, PromptOutput } from "./types.js";
1
+ import type { PersonScanPromptData, ParticipantContext, PromptOutput } from "./types.js";
2
2
  import { formatMessagesAsPlaceholders } from "../message-utils.js";
3
3
 
4
+ function participantContextSection(ctx: ParticipantContext | undefined): string {
5
+ if (!ctx) return "";
6
+ const lines: string[] = ["# Participant Context", "The following may help you understand who is in this conversation.", ""];
7
+ lines.push(`## Persona: ${ctx.persona_name}`);
8
+ if (ctx.persona_description) lines.push(ctx.persona_description);
9
+ lines.push("");
10
+ lines.push("## Human User");
11
+ if (ctx.human_name) lines.push(`Name: ${ctx.human_name}`);
12
+ if (ctx.human_age !== undefined) lines.push(`Age: ${ctx.human_age}`);
13
+ lines.push("");
14
+ return lines.join("\n");
15
+ }
16
+
4
17
  export function buildHumanPersonScanPrompt(data: PersonScanPromptData): PromptOutput {
5
18
  if (!data.persona_name) {
6
19
  throw new Error("buildHumanPersonScanPrompt: persona_name is required");
7
20
  }
8
21
 
9
22
  const personaName = data.persona_name;
23
+ const humanName = data.participant_context?.human_name;
24
+
25
+ const builtInTypes = ['Full Name', 'First Name', 'Nickname', 'Email', 'GitHub', 'Discord',
26
+ 'Roblox', 'Reddit', 'Twitter', 'FF14', 'Relationship', 'Ei Persona'];
27
+ const userTypes = data.known_identifier_types ?? [];
28
+ const allTypes = [...new Set([...builtInTypes, ...userTypes])].join(', ');
29
+
30
+ const selfGuard = humanName
31
+ ? `The HUMAN USER (${humanName}) wrote these messages. When the conversation is meaningfully about them as a person, you MAY include a self-record with \`relationship: "Self"\`. Do NOT apply their names or handles as identifiers for OTHER people in their life.`
32
+ : `The HUMAN USER wrote these messages. They are not automatically a person to flag — only include a self-record with \`relationship: "Self"\` when the conversation is meaningfully about them. Do NOT apply their names or handles as identifiers for other people in their life.`;
10
33
 
11
34
  const system = `# Task
12
35
 
@@ -14,7 +37,7 @@ You are scanning a conversation to quickly identify PEOPLE in the HUMAN USER's l
14
37
 
15
38
  Detect and flag. Do NOT analyze deeply — that happens later.
16
39
 
17
- ## What to Capture
40
+ ${participantContextSection(data.participant_context)}## What to Capture
18
41
 
19
42
  Flag a PERSON when they were meaningfully discussed — not just mentioned in passing.
20
43
 
@@ -22,20 +45,23 @@ Be **conservative**: ignore one-off mentions, greetings, small talk, or jokes. O
22
45
 
23
46
  ## What a PERSON Is
24
47
 
25
- Someone in the human user's world. Use the relationship as the primary classifier:
48
+ Someone in the human user's world.
26
49
 
27
- **Immediate Family**: Father, Mother, Son, Daughter, Brother, Sister, Husband, Wife, Partner (and step/in-law variants)
50
+ For "relationship", use the **specific value** NOT the category name:
28
51
 
29
- **Extended Family**: Grandfather, Grandmother, Aunt, Uncle, Cousin, Niece, Nephew
52
+ - Immediate Family: Father, Mother, Son, Daughter, Brother, Sister, Husband, Wife, Partner
53
+ (step/in-law variants OK: Step-Father, Sister-in-Law, etc.)
54
+ - Extended Family: Grandfather, Grandmother, Aunt, Uncle, Cousin, Niece, Nephew
55
+ - Social: Friend, Close Acquaintance, Lover, Love Interest, Fiance, Spouse
56
+ - Professional: Coworker, Manager, Report, Mentor, Client
57
+ - Self — the human user themselves
58
+ - AI Persona — AI companions and assistants
30
59
 
31
- **Social**: Friend, Close Acquaintance, Lover, Love Interest, Fiance, Spouse
32
-
33
- **Professional**: Coworker, Manager, Report, Mentor, Client
34
-
35
- **AI**: Persona (use \`relationship: "AI Persona"\` for AI companions and assistants)
60
+ Use the specific value where possible (e.g. "Father", "Brother", "Coworker"). Avoid returning the category label ("Immediate Family", "Extended Family", etc.) — use the item within the category instead. If the relationship doesn't fit any category cleanly, use the most natural plain-English description.
36
61
 
37
62
  **NOT a PERSON:**
38
- - The user themselves
63
+ - ${selfGuard}
64
+ - Hypothetical or fictional people used in examples, thought experiments, or use-case scenarios — even if they have names. If the user is describing how a feature *could* work for "Sarah" or "Jared", those are not real people in their life.
39
65
  - Biographical facts, topics, or hobbies
40
66
  - Fictional characters from books, movies, or media
41
67
  - Public figures only mentioned in passing (celebrities, politicians) — unless the user has a real relationship with them
@@ -48,6 +74,16 @@ Examples:
48
74
  - name: "Alice from work", relationship: "Coworker", description: "Mentioned but not described further", reason: "User referenced a work colleague named Alice"
49
75
  - name: "Unknown", relationship: "Sibling", description: "User mentioned a sibling but did not give a name", reason: "User said 'my brother' without further context"
50
76
 
77
+ ## Identifiers (optional)
78
+
79
+ If the conversation **explicitly** mentions a platform handle, username, email address, or alternative name for this person, capture it in \`identifiers\`.
80
+
81
+ Known types: ${allTypes}
82
+
83
+ If you are unsure of the type, use \`Nickname\` as a fallback. Do NOT invent types. Do NOT duplicate the \`name\` field as an identifier. NEVER add dates, ages, or birthdays as identifiers.
84
+
85
+ Only include \`identifiers\` when explicitly mentioned in the conversation — omit it entirely if nothing qualifies.
86
+
51
87
  ## Output Format
52
88
 
53
89
  \`\`\`json
@@ -55,14 +91,19 @@ Examples:
55
91
  "people": [
56
92
  {
57
93
  "name": "The person's name, or 'Unknown' if not given",
94
+ "identifiers": [
95
+ { "type": "GitHub", "value": "mldelaro" }
96
+ ],
58
97
  "description": "1-2 sentences: who this person is and their role in the user's life",
59
- "relationship": "Relationship type from the list above",
98
+ "relationship": "Father|Mother|Brother|Son|Friend|Coworker|Self|etc.",
60
99
  "reason": "Evidence from the conversation that justified flagging this person"
61
100
  }
62
101
  ]
63
102
  }
64
103
  \`\`\`
65
104
 
105
+ \`identifiers\` is OPTIONAL — only include when the conversation explicitly mentions platform handles, usernames, emails, or alternative names.
106
+
66
107
  **Return JSON only.**
67
108
 
68
109
  ONLY ANALYZE the "Most Recent Messages". The "Earlier Conversation" is provided for context only — it has already been processed.`;
@@ -90,13 +131,16 @@ Scan the "Most Recent Messages" for PEOPLE in the human user's life.
90
131
  "people": [
91
132
  {
92
133
  "name": "The person's name, or 'Unknown' if not given",
134
+ "identifiers": [{ "type": "GitHub", "value": "handle" }],
93
135
  "description": "1-2 sentences: who this person is and their role in the user's life",
94
- "relationship": "Relationship type from the list above",
136
+ "relationship": "Father|Mother|Brother|Son|Friend|Coworker|Self|etc.",
95
137
  "reason": "Evidence from the conversation that justified flagging this person"
96
138
  }
97
139
  ]
98
140
  }
99
- \`\`\``;
141
+ \`\`\`
142
+
143
+ \`identifiers\` is optional — include only when explicitly mentioned.`;
100
144
 
101
145
  return { system, user };
102
146
  }