ei-tui 1.2.0 → 1.3.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.
@@ -29,7 +29,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
29
29
  provider_id: "ei",
30
30
  name: "find_memory",
31
31
  display_name: "Find Memory",
32
- description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
32
+ description: "Semantic search of your personal memory — facts, topics, people, and quotes learned across ALL conversations over time, not just this one. Use when the human references something from the past, mentions a person, or asks about a topic you might have learned about. People and topic results include a sentiment field (e.g. '72% positive', 'neutral', '45% slightly negative') indicating how the human generally feels about that person or subject. Supports optional filters: types (array of 'facts', 'topics', 'people', 'quotes'), limit (1-20, default 10), recent (true = sort by recency), persona (filter to what a specific persona has learned — use display name).",
33
33
  input_schema: {
34
34
  type: "object",
35
35
  properties: {
@@ -52,7 +52,7 @@ export const SYSTEM_TOOLS: ToolDefinition[] = [
52
52
  provider_id: "ei",
53
53
  name: "fetch_memory",
54
54
  display_name: "Fetch Memory",
55
- description: "Retrieve the full record for a specific memory by its ID. Use when find_memory returns an item and you need its complete details, or when a system prompt references a memory ID. Returns the full Fact, Topic, Person, or Quote record.",
55
+ description: "Retrieve the full record for a specific memory by its ID. For most conversational use, find_memory results are sufficient. Use fetch_memory when you need provenance details (which sessions or documents the memory came from) or the raw sentiment score. Returns the complete Fact, Topic, Person, or Quote record including all fields.",
56
56
  input_schema: {
57
57
  type: "object",
58
58
  properties: {
@@ -27,15 +27,6 @@ export interface Message {
27
27
 
28
28
  external?: boolean; // Set by integration importers (OpenCode, Cursor, Claude Code); invisible to LLM context
29
29
 
30
- /**
31
- * Integration source tag. Set ONLY on external: true messages by importers (document, Slack, etc.)
32
- * to identify which external source this synthetic message came from.
33
- * Format: "import:document:filename" | "slack:channelId" | etc.
34
- * Enables quote provenance tracing: quote.message_id → message.source_tag → original source.
35
- * Never set on conversational messages.
36
- */
37
- source_tag?: string;
38
-
39
30
  }
40
31
 
41
32
  export interface ChatMessage {
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Fully-qualified message ID format:
3
+ * ei:${uuid}
4
+ * opencode:${machine}:${session}:${nativeId}
5
+ * claudecode:${machine}:${session}:${nativeId}
6
+ * cursor:${machine}:${session}:${nativeId}
7
+ * import:document:${slug}:${uuid}
8
+ * slack:${workspace}:${channel}:${ts}
9
+ */
10
+
11
+ export type MessageIdIntegration =
12
+ | "ei"
13
+ | "opencode"
14
+ | "claudecode"
15
+ | "cursor"
16
+ | "import"
17
+ | "slack"
18
+ | "unknown"
19
+
20
+ export interface ParsedMessageId {
21
+ integration: MessageIdIntegration
22
+ machine?: string
23
+ session?: string
24
+ nativeId: string
25
+ raw: string
26
+ }
27
+
28
+ export function parseMessageId(id: string): ParsedMessageId {
29
+ if (!id || typeof id !== "string") {
30
+ const raw = String(id ?? "")
31
+ return { integration: "unknown", nativeId: raw, raw }
32
+ }
33
+
34
+ const parts = id.split(":")
35
+
36
+ if (parts[0] === "ei" && parts.length === 2) {
37
+ return { integration: "ei", nativeId: parts[1], raw: id }
38
+ }
39
+
40
+ if (parts[0] === "opencode" && parts.length >= 4) {
41
+ return {
42
+ integration: "opencode",
43
+ machine: parts[1],
44
+ session: parts[2],
45
+ nativeId: parts.slice(3).join(":"),
46
+ raw: id,
47
+ }
48
+ }
49
+
50
+ if (parts[0] === "claudecode" && parts.length >= 4) {
51
+ return {
52
+ integration: "claudecode",
53
+ machine: parts[1],
54
+ session: parts[2],
55
+ nativeId: parts.slice(3).join(":"),
56
+ raw: id,
57
+ }
58
+ }
59
+
60
+ if (parts[0] === "cursor" && parts.length >= 4) {
61
+ return {
62
+ integration: "cursor",
63
+ machine: parts[1],
64
+ session: parts[2],
65
+ nativeId: parts.slice(3).join(":"),
66
+ raw: id,
67
+ }
68
+ }
69
+
70
+ if (parts[0] === "import" && parts[1] === "document" && parts.length >= 4) {
71
+ return {
72
+ integration: "import",
73
+ session: parts[2],
74
+ nativeId: parts.slice(3).join(":"),
75
+ raw: id,
76
+ }
77
+ }
78
+
79
+ if (parts[0] === "slack" && parts.length >= 4) {
80
+ return {
81
+ integration: "slack",
82
+ machine: parts[1],
83
+ session: parts[2],
84
+ nativeId: parts.slice(3).join(":"),
85
+ raw: id,
86
+ }
87
+ }
88
+
89
+ return { integration: "unknown", nativeId: id, raw: id }
90
+ }
91
+
92
+ export function isQualifiedMessageId(id: string): boolean {
93
+ return id.includes(":")
94
+ }
95
+
96
+ export function qualifyEiMessage(uuid: string): string {
97
+ return `ei:${uuid}`
98
+ }
99
+
100
+ export function qualifyOpenCodeMessage(machine: string, sessionId: string, nativeId: string): string {
101
+ return `opencode:${machine}:${sessionId}:${nativeId}`
102
+ }
103
+
104
+ export function qualifyClaudeCodeMessage(machine: string, sessionId: string, nativeId: string): string {
105
+ return `claudecode:${machine}:${sessionId}:${nativeId}`
106
+ }
107
+
108
+ export function qualifyCursorMessage(machine: string, sessionId: string, nativeId: string): string {
109
+ return `cursor:${machine}:${sessionId}:${nativeId}`
110
+ }
111
+
112
+ export function qualifyDocumentMessage(slug: string, uuid: string): string {
113
+ return `import:document:${slug}:${uuid}`
114
+ }
@@ -17,6 +17,7 @@ import {
17
17
  } from "../../core/orchestrators/ceremony.js";
18
18
  import { isProcessRunning } from "../process-check.js";
19
19
  import { getMachineId } from "../machine-id.js";
20
+ import { qualifyClaudeCodeMessage } from "../../core/utils/message-id.js";
20
21
 
21
22
  // =============================================================================
22
23
  // Export Types
@@ -43,9 +44,9 @@ export interface ClaudeCodeImporterOptions {
43
44
  const TWELVE_HOURS_MS = 43_200_000;
44
45
  const CLAUDE_CODE_GROUP = "Claude Code";
45
46
 
46
- function convertToEiMessage(msg: ClaudeCodeMessage): Message {
47
+ function convertToEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
47
48
  return {
48
- id: msg.id,
49
+ id: qualifyClaudeCodeMessage(getMachineId(), sessionId, msg.id),
49
50
  role: msg.role === "user" ? "human" : "system",
50
51
  content: msg.content,
51
52
  timestamp: msg.timestamp,
@@ -55,9 +56,9 @@ function convertToEiMessage(msg: ClaudeCodeMessage): Message {
55
56
  };
56
57
  }
57
58
 
58
- function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage): Message {
59
+ function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
59
60
  return {
60
- ...convertToEiMessage(msg),
61
+ ...convertToEiMessage(msg, sessionId),
61
62
  f: true,
62
63
  t: true,
63
64
  p: true,
@@ -248,7 +249,7 @@ export async function importClaudeCodeSessions(
248
249
  for (const msg of messages) {
249
250
  const msgMs = new Date(msg.timestamp).getTime();
250
251
  const isOld = cutoffMs !== null && msgMs < cutoffMs;
251
- const eiMsg = isOld ? convertToPreMarkedEiMessage(msg) : convertToEiMessage(msg);
252
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id) : convertToEiMessage(msg, targetSession.id);
252
253
  stateManager.messages_append(persona.id, eiMsg);
253
254
  result.messagesImported++;
254
255
  if (!isOld) toAnalyze.push(eiMsg);
@@ -9,6 +9,7 @@ import {
9
9
  import { CursorReader } from "./reader.js";
10
10
  import { isProcessRunning } from "../process-check.js";
11
11
  import { getMachineId } from "../machine-id.js";
12
+ import { qualifyCursorMessage } from "../../core/utils/message-id.js";
12
13
  import {
13
14
  queueAllScans,
14
15
  type ExtractionContext,
@@ -35,9 +36,9 @@ export interface CursorImporterOptions {
35
36
  const TWELVE_HOURS_MS = 43_200_000;
36
37
  const CURSOR_GROUP = "Cursor";
37
38
 
38
- function convertToEiMessage(msg: CursorMessage): Message {
39
+ function convertToEiMessage(msg: CursorMessage, sessionId: string): Message {
39
40
  return {
40
- id: msg.id,
41
+ id: qualifyCursorMessage(getMachineId(), sessionId, msg.id),
41
42
  role: msg.type === 1 ? "human" : "system",
42
43
  content: msg.text,
43
44
  timestamp: msg.timestamp,
@@ -47,9 +48,9 @@ function convertToEiMessage(msg: CursorMessage): Message {
47
48
  };
48
49
  }
49
50
 
50
- function convertToPreMarkedEiMessage(msg: CursorMessage): Message {
51
+ function convertToPreMarkedEiMessage(msg: CursorMessage, sessionId: string): Message {
51
52
  return {
52
- ...convertToEiMessage(msg),
53
+ ...convertToEiMessage(msg, sessionId),
53
54
  f: true,
54
55
  t: true,
55
56
  p: true,
@@ -208,7 +209,7 @@ export async function importCursorSessions(
208
209
  for (const msg of messages) {
209
210
  const msgMs = new Date(msg.timestamp).getTime();
210
211
  const isOld = cutoffMs !== null && msgMs < cutoffMs;
211
- const eiMsg = isOld ? convertToPreMarkedEiMessage(msg) : convertToEiMessage(msg);
212
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id) : convertToEiMessage(msg, targetSession.id);
212
213
  stateManager.messages_append(persona.id, eiMsg);
213
214
  result.messagesImported++;
214
215
  if (!isOld) toAnalyze.push(eiMsg);
@@ -42,7 +42,7 @@ export async function importDocument(options: DocumentImportOptions): Promise<Do
42
42
  const sourceTag = `import:document:${filename}`;
43
43
  const existingMsgs = stateManager.messages_get("emmet");
44
44
  const staleIds = existingMsgs
45
- .filter(m => m.external === true && m.source_tag === sourceTag)
45
+ .filter(m => m.external === true && m.id.startsWith(`${sourceTag}:`))
46
46
  .map(m => m.id);
47
47
  if (staleIds.length > 0) {
48
48
  stateManager.messages_remove("emmet", staleIds);
@@ -63,15 +63,10 @@ export function previewUnsource(sourceTag: string, stateManager: StateManager):
63
63
  }
64
64
  }
65
65
 
66
- const emmettMessages = stateManager.messages_get("emmet");
67
- const sourceMessageIds = new Set(
68
- emmettMessages
69
- .filter(m => m.source_tag === sourceTag)
70
- .map(m => m.id)
71
- );
66
+ const msgIdPrefix = `${sourceTag}:`;
72
67
 
73
68
  for (const quote of human.quotes) {
74
- if (quote.message_id && sourceMessageIds.has(quote.message_id)) {
69
+ if (quote.message_id?.startsWith(msgIdPrefix)) {
75
70
  preview.toDelete.quotes.push({ id: quote.id, text: quote.text });
76
71
  }
77
72
  }
@@ -143,11 +138,11 @@ export async function executeUnsource(
143
138
  stateManager.setHuman(human);
144
139
  }
145
140
 
146
- const sourceMessageIds = stateManager.messages_get("emmet")
147
- .filter(m => m.source_tag === preview.sourceTag)
141
+ const idsToRemove = stateManager.messages_get("emmet")
142
+ .filter(m => m.id.startsWith(`${preview.sourceTag}:`))
148
143
  .map(m => m.id);
149
- if (sourceMessageIds.length > 0) {
150
- stateManager.messages_remove("emmet", sourceMessageIds);
144
+ if (idsToRemove.length > 0) {
145
+ stateManager.messages_remove("emmet", idsToRemove);
151
146
  }
152
147
 
153
148
  const key = preview.sourceTag.startsWith("import:document:")
@@ -14,6 +14,7 @@ import {
14
14
  } from "../../core/orchestrators/ceremony.js";
15
15
  import { isProcessRunning } from "../process-check.js";
16
16
  import { getMachineId } from "../machine-id.js";
17
+ import { qualifyOpenCodeMessage } from "../../core/utils/message-id.js";
17
18
 
18
19
  // =============================================================================
19
20
  // Constants
@@ -47,9 +48,9 @@ function isAgentToAgentMessage(content: string): boolean {
47
48
  return AGENT_TO_AGENT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
48
49
  }
49
50
 
50
- function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
51
+ function convertToEiMessage(ocMsg: OpenCodeMessage, sessionId: string): Message {
51
52
  return {
52
- id: ocMsg.id,
53
+ id: qualifyOpenCodeMessage(getMachineId(), sessionId, ocMsg.id),
53
54
  role: ocMsg.role === "user" ? "human" : "system",
54
55
  content: ocMsg.content,
55
56
  timestamp: ocMsg.timestamp,
@@ -59,9 +60,9 @@ function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
59
60
  };
60
61
  }
61
62
 
62
- function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage): Message {
63
+ function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage, sessionId: string): Message {
63
64
  return {
64
- ...convertToEiMessage(ocMsg),
65
+ ...convertToEiMessage(ocMsg, sessionId),
65
66
  f: true,
66
67
  t: true,
67
68
  p: true,
@@ -232,7 +233,7 @@ export async function importOpenCodeSessions(
232
233
  for (const ocMsg of agentMsgs) {
233
234
  const msgMs = new Date(ocMsg.timestamp).getTime();
234
235
  const isOld = cutoffMs !== null && msgMs < cutoffMs;
235
- const eiMsg = isOld ? convertToPreMarkedEiMessage(ocMsg) : convertToEiMessage(ocMsg);
236
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(ocMsg, targetSession.id) : convertToEiMessage(ocMsg, targetSession.id);
236
237
  stateManager.messages_append(persona.id, eiMsg);
237
238
  result.messagesImported++;
238
239
  if (!isOld) toAnalyze.push(eiMsg);
@@ -4,6 +4,7 @@ import type {
4
4
  OpenCodeSessionRaw,
5
5
  OpenCodeMessage,
6
6
  OpenCodeMessageRaw,
7
+ OpenCodeMessageWindow,
7
8
  OpenCodePartRaw,
8
9
  OpenCodeAgent,
9
10
  } from "./types.js";
@@ -208,6 +209,70 @@ export class JsonReader implements IOpenCodeReader {
208
209
  );
209
210
  }
210
211
 
212
+ async getMessageById(messageId: string, before = 0, after = 0): Promise<OpenCodeMessageWindow | null> {
213
+ if (!(await this.init())) return null;
214
+
215
+ const messageBaseDir = _join(this.storagePath!, "message");
216
+ let sessionDirs: string[];
217
+ try {
218
+ sessionDirs = await _readdir(messageBaseDir);
219
+ } catch {
220
+ return null;
221
+ }
222
+
223
+ for (const sessionId of sessionDirs) {
224
+ if (sessionId.startsWith(".")) continue;
225
+ const sessionMsgDir = _join(messageBaseDir, sessionId);
226
+ let files: string[];
227
+ try {
228
+ files = await _readdir(sessionMsgDir);
229
+ } catch {
230
+ continue;
231
+ }
232
+ if (!files.includes(`${messageId}.json`)) continue;
233
+
234
+ const allMessages = await this.getMessagesForSession(sessionId);
235
+ const idx = allMessages.findIndex(m => m.id === messageId);
236
+ if (idx === -1) return null;
237
+
238
+ const sessionFilePath = await this.findSessionFile(sessionId);
239
+ const sessionRaw = sessionFilePath ? await this.readJsonFile<OpenCodeSessionRaw>(sessionFilePath) : null;
240
+ const session: OpenCodeSession = sessionRaw
241
+ ? { id: sessionRaw.id, title: sessionRaw.title, directory: sessionRaw.directory, projectId: sessionRaw.projectID, parentId: sessionRaw.parentID, time: { created: sessionRaw.time.created, updated: sessionRaw.time.updated } }
242
+ : { id: sessionId, title: "", directory: "", projectId: "", time: { created: 0, updated: 0 } };
243
+
244
+ return {
245
+ message: allMessages[idx],
246
+ before: allMessages.slice(Math.max(0, idx - before), idx),
247
+ after: allMessages.slice(idx + 1, idx + 1 + after),
248
+ session,
249
+ };
250
+ }
251
+
252
+ return null;
253
+ }
254
+
255
+ private async findSessionFile(sessionId: string): Promise<string | null> {
256
+ const sessionDir = _join(this.storagePath!, "session");
257
+ let projectDirs: string[];
258
+ try {
259
+ projectDirs = await _readdir(sessionDir);
260
+ } catch {
261
+ return null;
262
+ }
263
+ for (const projectHash of projectDirs) {
264
+ if (projectHash.startsWith(".")) continue;
265
+ const candidate = _join(sessionDir, projectHash, `${sessionId}.json`);
266
+ try {
267
+ await _readFile(candidate, "utf-8");
268
+ return candidate;
269
+ } catch {
270
+ continue;
271
+ }
272
+ }
273
+ return null;
274
+ }
275
+
211
276
  async getAgentInfo(agentName: string): Promise<OpenCodeAgent | null> {
212
277
  const normalized = agentName.toLowerCase();
213
278
  if (BUILTIN_AGENTS[normalized]) {
@@ -3,6 +3,7 @@ import type {
3
3
  IOpenCodeReader,
4
4
  OpenCodeSession,
5
5
  OpenCodeMessage,
6
+ OpenCodeMessageWindow,
6
7
  OpenCodeAgent,
7
8
  } from "./types.js";
8
9
  import { BUILTIN_AGENTS } from "./types.js";
@@ -153,6 +154,38 @@ export class SqliteReader implements IOpenCodeReader {
153
154
  return textParts.length > 0 ? textParts.join("\n\n") : null;
154
155
  }
155
156
 
157
+ async getMessageById(messageId: string, before = 0, after = 0): Promise<OpenCodeMessageWindow | null> {
158
+ const row = this.db
159
+ .query(`SELECT session_id FROM message WHERE id = ?1 LIMIT 1`)
160
+ .get(messageId) as { session_id: string } | null;
161
+ if (!row) return null;
162
+
163
+ const sessionRow = this.db
164
+ .query(`SELECT id, title, directory, project_id, parent_id, time_created, time_updated FROM session WHERE id = ?1 LIMIT 1`)
165
+ .get(row.session_id) as { id: string; title: string; directory: string; project_id: string; parent_id: string | null; time_created: number; time_updated: number } | null;
166
+ if (!sessionRow) return null;
167
+
168
+ const session: OpenCodeSession = {
169
+ id: sessionRow.id,
170
+ title: sessionRow.title,
171
+ directory: sessionRow.directory,
172
+ projectId: sessionRow.project_id,
173
+ parentId: sessionRow.parent_id ?? undefined,
174
+ time: { created: sessionRow.time_created, updated: sessionRow.time_updated },
175
+ };
176
+
177
+ const allMessages = await this.getMessagesForSession(row.session_id);
178
+ const idx = allMessages.findIndex(m => m.id === messageId);
179
+ if (idx === -1) return null;
180
+
181
+ return {
182
+ message: allMessages[idx],
183
+ before: allMessages.slice(Math.max(0, idx - before), idx),
184
+ after: allMessages.slice(idx + 1, idx + 1 + after),
185
+ session,
186
+ };
187
+ }
188
+
156
189
  async getAgentInfo(agentName: string): Promise<OpenCodeAgent | null> {
157
190
  const normalized = agentName.toLowerCase();
158
191
  if (BUILTIN_AGENTS[normalized]) {
@@ -14,10 +14,18 @@
14
14
  * Common interface for reading OpenCode data.
15
15
  * Implemented by both JsonReader (legacy) and SqliteReader (1.2+).
16
16
  */
17
+ export interface OpenCodeMessageWindow {
18
+ message: OpenCodeMessage;
19
+ before: OpenCodeMessage[];
20
+ after: OpenCodeMessage[];
21
+ session: OpenCodeSession;
22
+ }
23
+
17
24
  export interface IOpenCodeReader {
18
25
  getSessionsUpdatedSince(since: Date): Promise<OpenCodeSession[]>;
19
26
  getSessionsInRange(from: Date, to: Date): Promise<OpenCodeSession[]>;
20
27
  getMessagesForSession(sessionId: string, since?: Date): Promise<OpenCodeMessage[]>;
28
+ getMessageById(messageId: string, before?: number, after?: number): Promise<OpenCodeMessageWindow | null>;
21
29
  getAgentInfo(agentName: string): Promise<OpenCodeAgent | null>;
22
30
  getAllUniqueAgents(sessionId: string): Promise<string[]>;
23
31
  getFirstAgent(sessionId: string): Promise<string | null>;
@@ -9,6 +9,7 @@ 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
11
  import { getMessageContent } from "../../core/handlers/utils.js";
12
+ import { partitionTraits } from "../trait-utils.js";
12
13
  function formatTopicsWithGaps(topics: Topic[]): string {
13
14
  if (topics.length === 0) return "(No topics with engagement gaps)";
14
15
 
@@ -85,13 +86,15 @@ export function buildHeartbeatCheckPrompt(data: HeartbeatCheckPromptData): Promp
85
86
 
86
87
  You are NOT having a conversation right now - you are deciding IF you should start one.`;
87
88
 
89
+ const { active: activeTraits } = partitionTraits(data.persona.traits);
90
+
88
91
  const contextFragment = `## Context
89
92
 
90
93
  It has been ${data.inactive_days} day${data.inactive_days !== 1 ? 's' : ''} since your last interaction.
91
94
 
92
95
  ### Your Personality
93
- ${data.persona.traits.length > 0
94
- ? data.persona.traits.map(t => `- **${t.name}**: ${t.description}`).join('\n')
96
+ ${activeTraits.length > 0
97
+ ? activeTraits.map(t => `- **${t.name}**: ${t.description}`).join('\n')
95
98
  : "(No specific traits defined)"}
96
99
 
97
100
  ### Topics You Care About
@@ -41,6 +41,12 @@ function formatItem(item: EiHeartbeatItem): string {
41
41
  ` ${item.critique}`,
42
42
  ].join("\n");
43
43
 
44
+ case "Self Reflection Alert":
45
+ return [
46
+ `- **${item.id}** Self Reflection Alert (your own identity)`,
47
+ ` ${item.critique}`,
48
+ ].join("\n");
49
+
44
50
  default:
45
51
  return '';
46
52
  }
@@ -86,6 +92,7 @@ ${itemsSection}
86
92
  - **Low-Engagement Person / Topic**: Write a natural, warm message that naturally brings up this person or topic. Set the id and my_response.
87
93
  - **Inactive Persona**: Write a message that gently mentions the persona might be worth checking in with. Set the id and my_response.
88
94
  - **Persona Reflection Alert**: The nightly review proposed identity changes for this persona. Mention it naturally — the user can talk to the persona and then use the command shown in the status bar to review the changes. Set the id and my_response.
95
+ - **Self Reflection Alert**: The nightly review proposed changes to *your own* identity. Mention it naturally — you've grown and the system noticed. The user can review your proposed changes using the command shown in the status bar. Set the id and my_response.
89
96
 
90
97
  ## When NOT to Reach Out
91
98
 
@@ -94,6 +94,11 @@ export type EiHeartbeatItem =
94
94
  type: "Persona Reflection Alert";
95
95
  persona_name: string;
96
96
  critique: string;
97
+ }
98
+ | {
99
+ id: string;
100
+ type: "Self Reflection Alert";
101
+ critique: string;
97
102
  };
98
103
 
99
104
  /**
@@ -6,6 +6,7 @@
6
6
  import type { PersonaTrait, Quote, PersonaTopic } from "../../core/types.js";
7
7
  import type { ResponsePromptData, TemporalAnchor } from "./types.js";
8
8
  import { formatTimestamp } from "../../core/format-utils.js";
9
+ import { partitionTraits, bucketTraits } from "../trait-utils.js";
9
10
 
10
11
  const DESCRIPTION_MAX_CHARS = 500;
11
12
 
@@ -69,25 +70,35 @@ export function buildGuidelinesSection(personaName: string): string {
69
70
  // TRAITS SECTION
70
71
  // =============================================================================
71
72
 
73
+ const TRAIT_BUCKETS = [
74
+ { min: 90, max: 100, header: "### Core Expression\nThese define you. They should be evident in every response." },
75
+ { min: 66, max: 89, header: "### Strong Tendencies\nFrequent and traceable, but not in every sentence." },
76
+ { min: 36, max: 65, header: "### Noticeable in Casual Messages\nPresent but not dominating — surfaces naturally, not constantly." },
77
+ { min: 1, max: 35, header: "### Subtle Undercurrents\nBackground texture only. Use sparingly or subtly." },
78
+ ] as const;
79
+
72
80
  export function buildTraitsSection(traits: PersonaTrait[], header: string): string {
73
81
  if (traits.length === 0) return "";
74
-
75
- const sorted = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 15);
76
- const formatted = sorted.map(t => {
77
- const strength = t.strength !== undefined ? ` (${Math.round(t.strength * 100)}%)` : "";
78
- return `- **${t.name}**${strength}: ${truncateDescription(t.description)}`;
79
- }).join("\n");
80
-
81
- return `## ${header}
82
82
 
83
- > NOTE: Strength of a trait should guide you to your response style, meaning a Strength of:
84
- > - 0% should never be used - the user has asked you to stop
85
- > - 25% should be used sparingly or subtly
86
- > - 50% should be noticable in casual messages, but not dominating
87
- > - 75% should be frequently used, but not in every resposne or throughout the entire conversation
88
- > - 100% should be tracable throughout every response
83
+ const capped = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 15);
84
+ const { guardrails, active } = partitionTraits(capped);
89
85
 
90
- ${formatted}`;
86
+ const sections: string[] = [];
87
+
88
+ if (guardrails.length > 0) {
89
+ const lines = guardrails.map(t => `**${t.name}**`).join("\n");
90
+ sections.push(`### Must NEVER Do — User Explicitly Asked You To Stop\n${lines}`);
91
+ }
92
+
93
+ for (const { bucket, traits: inBucket } of bucketTraits(active, TRAIT_BUCKETS)) {
94
+ if (inBucket.length === 0) continue;
95
+ const lines = inBucket.map(t => `**${t.name}**: ${truncateDescription(t.description)}`).join("\n");
96
+ sections.push(`${bucket.header}\n${lines}`);
97
+ }
98
+
99
+ if (sections.length === 0) return "";
100
+
101
+ return `## ${header}\n\n${sections.join("\n\n")}`;
91
102
  }
92
103
 
93
104
  // =============================================================================
@@ -310,6 +321,9 @@ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["h
310
321
  .filter((name): name is string => name !== undefined);
311
322
 
312
323
  let line = `- "${q.text}" — ${speaker} (${date})`;
324
+ if (q.message_id) {
325
+ line += `\n → fetch_message("${q.message_id}") for surrounding context`;
326
+ }
313
327
  if (linkedNames.length > 0) {
314
328
  line += `\n Related to: ${linkedNames.join(", ")}`;
315
329
  }
@@ -318,7 +332,7 @@ export function buildQuotesSection(quotes: Quote[], human: ResponsePromptData["h
318
332
 
319
333
  return `## Memorable Moments
320
334
 
321
- These are quotes the human or the system found worth preserving:
335
+ These are quotes the human or the system found worth preserving. If one feels relevant, use fetch_message(message_id) to pull the surrounding conversation:
322
336
 
323
337
  ${formatted}`;
324
338
  }
@@ -5,6 +5,7 @@
5
5
  import type { RoomParticipantIdentity, RoomHistoryMessage, RoomJudgeCandidate } from "./types.js";
6
6
  import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
7
7
  import { RoomMode } from "../../core/types.js";
8
+ import { partitionTraits, bucketTraits } from "../trait-utils.js";
8
9
 
9
10
  const DESCRIPTION_MAX_CHARS = 500;
10
11
 
@@ -70,14 +71,35 @@ export function buildRoomGuidelinesSection(personaName: string, mode?: RoomMode)
70
71
  return baseGuidelines;
71
72
  }
72
73
 
74
+ const ROOM_TRAIT_BUCKETS = [
75
+ { min: 90, max: 100, header: "### Core Expression\nEvident in every response." },
76
+ { min: 66, max: 89, header: "### Strong Tendencies\nFrequent, but not every sentence." },
77
+ { min: 36, max: 65, header: "### Noticeable in Casual Messages\nSurfaces naturally, not constantly." },
78
+ { min: 1, max: 35, header: "### Subtle Undercurrents\nBackground texture only." },
79
+ ] as const;
80
+
73
81
  export function buildRoomTraitsSection(traits: PersonaTrait[]): string {
74
82
  if (traits.length === 0) return "";
75
- const sorted = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 12);
76
- const lines = sorted.map(t => {
77
- const pct = t.strength !== undefined ? ` (${Math.round(t.strength * 100)}%)` : "";
78
- return `- **${t.name}**${pct}: ${truncate(t.description)}`;
79
- });
80
- return `## Your Personality\n\n${lines.join("\n")}`;
83
+
84
+ const capped = [...traits].sort((a, b) => (b.strength ?? 0.5) - (a.strength ?? 0.5)).slice(0, 12);
85
+ const { guardrails, active } = partitionTraits(capped);
86
+
87
+ const sections: string[] = [];
88
+
89
+ if (guardrails.length > 0) {
90
+ const lines = guardrails.map(t => `**${t.name}**`).join("\n");
91
+ sections.push(`### Must NEVER Do — User Explicitly Asked You To Stop\n${lines}`);
92
+ }
93
+
94
+ for (const { bucket, traits: inBucket } of bucketTraits(active, ROOM_TRAIT_BUCKETS)) {
95
+ if (inBucket.length === 0) continue;
96
+ const lines = inBucket.map(t => `**${t.name}**: ${truncate(t.description)}`).join("\n");
97
+ sections.push(`${bucket.header}\n${lines}`);
98
+ }
99
+
100
+ if (sections.length === 0) return "";
101
+
102
+ return `## Your Personality\n\n${sections.join("\n\n")}`;
81
103
  }
82
104
 
83
105
  export function buildRoomTopicsSection(topics: PersonaTopic[]): string {