ei-tui 1.1.0 → 1.3.0

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 (66) hide show
  1. package/README.md +16 -0
  2. package/package.json +2 -23
  3. package/src/cli/README.md +12 -2
  4. package/src/cli/mcp.ts +12 -4
  5. package/src/cli/retrieval.ts +162 -0
  6. package/src/cli.ts +7 -1
  7. package/src/core/handlers/dedup.ts +4 -15
  8. package/src/core/handlers/document-segmentation.ts +5 -7
  9. package/src/core/handlers/heartbeat.ts +5 -10
  10. package/src/core/handlers/human-matching.ts +8 -0
  11. package/src/core/handlers/index.ts +2 -0
  12. package/src/core/handlers/knowledge-synthesis.ts +48 -0
  13. package/src/core/handlers/persona-generation.ts +4 -8
  14. package/src/core/handlers/persona-response.ts +3 -4
  15. package/src/core/handlers/persona-topics.ts +2 -4
  16. package/src/core/handlers/rewrite.ts +26 -9
  17. package/src/core/handlers/rooms.ts +6 -12
  18. package/src/core/heartbeat-manager.ts +10 -0
  19. package/src/core/llm-client.ts +13 -3
  20. package/src/core/message-manager.ts +2 -4
  21. package/src/core/orchestrators/ceremony.ts +45 -22
  22. package/src/core/orchestrators/human-extraction.ts +10 -1
  23. package/src/core/processor.ts +275 -7
  24. package/src/core/queue-manager.ts +10 -0
  25. package/src/core/state-manager.ts +35 -0
  26. package/src/core/tools/builtin/fetch-memory.ts +6 -6
  27. package/src/core/tools/builtin/fetch-message.ts +27 -1
  28. package/src/core/tools/builtin/find-memory.ts +11 -3
  29. package/src/core/tools/index.ts +3 -3
  30. package/src/core/tools/types.ts +1 -1
  31. package/src/core/types/data-items.ts +1 -1
  32. package/src/core/types/entities.ts +7 -1
  33. package/src/core/types/enums.ts +1 -0
  34. package/src/core/types/integrations.ts +3 -1
  35. package/src/core/types/llm.ts +0 -9
  36. package/src/core/utils/message-id.ts +114 -0
  37. package/src/integrations/claude-code/importer.ts +12 -5
  38. package/src/integrations/cursor/importer.ts +12 -5
  39. package/src/integrations/document/importer.ts +1 -1
  40. package/src/integrations/document/unsource.ts +11 -14
  41. package/src/integrations/opencode/importer.ts +19 -6
  42. package/src/integrations/opencode/json-reader.ts +65 -0
  43. package/src/integrations/opencode/sqlite-reader.ts +33 -0
  44. package/src/integrations/opencode/types.ts +8 -0
  45. package/src/integrations/persona-history/importer.ts +9 -0
  46. package/src/prompts/ceremony/people-rewrite.ts +2 -2
  47. package/src/prompts/ceremony/topic-rewrite.ts +2 -2
  48. package/src/prompts/heartbeat/check.ts +5 -2
  49. package/src/prompts/heartbeat/ei.ts +7 -0
  50. package/src/prompts/heartbeat/types.ts +5 -0
  51. package/src/prompts/index.ts +3 -0
  52. package/src/prompts/response/sections.ts +30 -16
  53. package/src/prompts/room/sections.ts +28 -6
  54. package/src/prompts/synthesis/index.ts +101 -0
  55. package/src/prompts/synthesis/types.ts +26 -0
  56. package/src/prompts/trait-utils.ts +33 -0
  57. package/tui/README.md +2 -0
  58. package/tui/src/commands/generate.tsx +98 -0
  59. package/tui/src/commands/unsource.tsx +17 -10
  60. package/tui/src/components/GeneratedDocsOverlay.tsx +136 -0
  61. package/tui/src/components/PromptInput.tsx +2 -0
  62. package/tui/src/context/ei.tsx +49 -2
  63. package/tui/src/util/help-content.ts +11 -0
  64. package/tui/src/util/logger.ts +22 -2
  65. package/tui/src/util/provider-detection.ts +5 -2
  66. package/tui/src/util/yaml-provider.ts +2 -8
@@ -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
+ }
@@ -11,8 +11,13 @@ import {
11
11
  queueAllScans,
12
12
  type ExtractionContext,
13
13
  } from "../../core/orchestrators/human-extraction.js";
14
+ import {
15
+ queuePersonRewritePhase,
16
+ queueTopicRewritePhase,
17
+ } from "../../core/orchestrators/ceremony.js";
14
18
  import { isProcessRunning } from "../process-check.js";
15
19
  import { getMachineId } from "../machine-id.js";
20
+ import { qualifyClaudeCodeMessage } from "../../core/utils/message-id.js";
16
21
 
17
22
  // =============================================================================
18
23
  // Export Types
@@ -39,9 +44,9 @@ export interface ClaudeCodeImporterOptions {
39
44
  const TWELVE_HOURS_MS = 43_200_000;
40
45
  const CLAUDE_CODE_GROUP = "Claude Code";
41
46
 
42
- function convertToEiMessage(msg: ClaudeCodeMessage): Message {
47
+ function convertToEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
43
48
  return {
44
- id: msg.id,
49
+ id: qualifyClaudeCodeMessage(getMachineId(), sessionId, msg.id),
45
50
  role: msg.role === "user" ? "human" : "system",
46
51
  content: msg.content,
47
52
  timestamp: msg.timestamp,
@@ -51,9 +56,9 @@ function convertToEiMessage(msg: ClaudeCodeMessage): Message {
51
56
  };
52
57
  }
53
58
 
54
- function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage): Message {
59
+ function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage, sessionId: string): Message {
55
60
  return {
56
- ...convertToEiMessage(msg),
61
+ ...convertToEiMessage(msg, sessionId),
57
62
  f: true,
58
63
  t: true,
59
64
  p: true,
@@ -244,7 +249,7 @@ export async function importClaudeCodeSessions(
244
249
  for (const msg of messages) {
245
250
  const msgMs = new Date(msg.timestamp).getTime();
246
251
  const isOld = cutoffMs !== null && msgMs < cutoffMs;
247
- const eiMsg = isOld ? convertToPreMarkedEiMessage(msg) : convertToEiMessage(msg);
252
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id) : convertToEiMessage(msg, targetSession.id);
248
253
  stateManager.messages_append(persona.id, eiMsg);
249
254
  result.messagesImported++;
250
255
  if (!isOld) toAnalyze.push(eiMsg);
@@ -268,6 +273,8 @@ export async function importClaudeCodeSessions(
268
273
  sources: [`claudecode:${getMachineId()}:${targetSession.id}`],
269
274
  };
270
275
 
276
+ queuePersonRewritePhase(stateManager);
277
+ queueTopicRewritePhase(stateManager);
271
278
  const ccSettings = stateManager.getHuman().settings?.claudeCode;
272
279
  queueAllScans(context, stateManager, {
273
280
  extraction_model: ccSettings?.extraction_model,
@@ -9,10 +9,15 @@ 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,
15
16
  } from "../../core/orchestrators/human-extraction.js";
17
+ import {
18
+ queuePersonRewritePhase,
19
+ queueTopicRewritePhase,
20
+ } from "../../core/orchestrators/ceremony.js";
16
21
 
17
22
  export interface CursorImportResult {
18
23
  sessionsProcessed: number;
@@ -31,9 +36,9 @@ export interface CursorImporterOptions {
31
36
  const TWELVE_HOURS_MS = 43_200_000;
32
37
  const CURSOR_GROUP = "Cursor";
33
38
 
34
- function convertToEiMessage(msg: CursorMessage): Message {
39
+ function convertToEiMessage(msg: CursorMessage, sessionId: string): Message {
35
40
  return {
36
- id: msg.id,
41
+ id: qualifyCursorMessage(getMachineId(), sessionId, msg.id),
37
42
  role: msg.type === 1 ? "human" : "system",
38
43
  content: msg.text,
39
44
  timestamp: msg.timestamp,
@@ -43,9 +48,9 @@ function convertToEiMessage(msg: CursorMessage): Message {
43
48
  };
44
49
  }
45
50
 
46
- function convertToPreMarkedEiMessage(msg: CursorMessage): Message {
51
+ function convertToPreMarkedEiMessage(msg: CursorMessage, sessionId: string): Message {
47
52
  return {
48
- ...convertToEiMessage(msg),
53
+ ...convertToEiMessage(msg, sessionId),
49
54
  f: true,
50
55
  t: true,
51
56
  p: true,
@@ -204,7 +209,7 @@ export async function importCursorSessions(
204
209
  for (const msg of messages) {
205
210
  const msgMs = new Date(msg.timestamp).getTime();
206
211
  const isOld = cutoffMs !== null && msgMs < cutoffMs;
207
- const eiMsg = isOld ? convertToPreMarkedEiMessage(msg) : convertToEiMessage(msg);
212
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(msg, targetSession.id) : convertToEiMessage(msg, targetSession.id);
208
213
  stateManager.messages_append(persona.id, eiMsg);
209
214
  result.messagesImported++;
210
215
  if (!isOld) toAnalyze.push(eiMsg);
@@ -227,6 +232,8 @@ export async function importCursorSessions(
227
232
  sources: [`cursor:${getMachineId()}:${targetSession.id}`],
228
233
  };
229
234
 
235
+ queuePersonRewritePhase(stateManager);
236
+ queueTopicRewritePhase(stateManager);
230
237
  queueAllScans(context, stateManager, { external_filter: "only" });
231
238
  result.extractionScansQueued += 4;
232
239
  }
@@ -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,20 +138,22 @@ 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
- const filename = preview.sourceTag.startsWith("import:document:")
148
+ const key = preview.sourceTag.startsWith("import:document:")
154
149
  ? preview.sourceTag.slice("import:document:".length)
155
- : preview.sourceTag;
150
+ : preview.sourceTag.startsWith("generate:document:")
151
+ ? preview.sourceTag.slice("generate:document:".length)
152
+ : preview.sourceTag;
156
153
 
157
154
  const human = stateManager.getHuman();
158
155
  if (human.settings?.document?.processed_documents) {
159
- delete human.settings.document.processed_documents[filename];
156
+ delete human.settings.document.processed_documents[key];
160
157
  stateManager.setHuman(human);
161
158
  }
162
159
 
@@ -8,8 +8,13 @@ import {
8
8
  queueAllScans,
9
9
  type ExtractionContext,
10
10
  } from "../../core/orchestrators/human-extraction.js";
11
+ import {
12
+ queuePersonRewritePhase,
13
+ queueTopicRewritePhase,
14
+ } from "../../core/orchestrators/ceremony.js";
11
15
  import { isProcessRunning } from "../process-check.js";
12
16
  import { getMachineId } from "../machine-id.js";
17
+ import { qualifyOpenCodeMessage } from "../../core/utils/message-id.js";
13
18
 
14
19
  // =============================================================================
15
20
  // Constants
@@ -43,9 +48,9 @@ function isAgentToAgentMessage(content: string): boolean {
43
48
  return AGENT_TO_AGENT_PREFIXES.some(prefix => trimmed.startsWith(prefix));
44
49
  }
45
50
 
46
- function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
51
+ function convertToEiMessage(ocMsg: OpenCodeMessage, sessionId: string): Message {
47
52
  return {
48
- id: ocMsg.id,
53
+ id: qualifyOpenCodeMessage(getMachineId(), sessionId, ocMsg.id),
49
54
  role: ocMsg.role === "user" ? "human" : "system",
50
55
  content: ocMsg.content,
51
56
  timestamp: ocMsg.timestamp,
@@ -55,9 +60,9 @@ function convertToEiMessage(ocMsg: OpenCodeMessage): Message {
55
60
  };
56
61
  }
57
62
 
58
- function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage): Message {
63
+ function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage, sessionId: string): Message {
59
64
  return {
60
- ...convertToEiMessage(ocMsg),
65
+ ...convertToEiMessage(ocMsg, sessionId),
61
66
  f: true,
62
67
  t: true,
63
68
  p: true,
@@ -195,6 +200,7 @@ export async function importOpenCodeSessions(
195
200
 
196
201
  const cutoffIso = processedSessions[targetSession.id] ?? null;
197
202
  const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
203
+ let anyPersonaHasChanges = false;
198
204
 
199
205
  for (const [, { persona, msgs: agentMsgs, isNew, agentName }] of byPersonaId) {
200
206
  if (isNew) {
@@ -227,7 +233,7 @@ export async function importOpenCodeSessions(
227
233
  for (const ocMsg of agentMsgs) {
228
234
  const msgMs = new Date(ocMsg.timestamp).getTime();
229
235
  const isOld = cutoffMs !== null && msgMs < cutoffMs;
230
- const eiMsg = isOld ? convertToPreMarkedEiMessage(ocMsg) : convertToEiMessage(ocMsg);
236
+ const eiMsg = isOld ? convertToPreMarkedEiMessage(ocMsg, targetSession.id) : convertToEiMessage(ocMsg, targetSession.id);
231
237
  stateManager.messages_append(persona.id, eiMsg);
232
238
  result.messagesImported++;
233
239
  if (!isOld) toAnalyze.push(eiMsg);
@@ -252,6 +258,7 @@ export async function importOpenCodeSessions(
252
258
  };
253
259
 
254
260
  if (!signal?.aborted) {
261
+ anyPersonaHasChanges = true;
255
262
  const openCodeSettings = stateManager.getHuman().settings?.opencode;
256
263
  queueAllScans(context, stateManager, {
257
264
  extraction_model: openCodeSettings?.extraction_model,
@@ -264,7 +271,13 @@ export async function importOpenCodeSessions(
264
271
 
265
272
  result.sessionsProcessed = 1;
266
273
 
267
- // ─── Step 6: Advance extraction state ────────────────────────────────
274
+ // ─── Step 6: Queue rewrite checks if any persona had new messages ─────
275
+ if (anyPersonaHasChanges && !signal?.aborted) {
276
+ queuePersonRewritePhase(stateManager);
277
+ queueTopicRewritePhase(stateManager);
278
+ }
279
+
280
+ // ─── Step 7: Advance extraction state ────────────────────────────────
268
281
  updateExtractionState(stateManager, targetSession);
269
282
 
270
283
  console.log(
@@ -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>;
@@ -6,6 +6,10 @@ import {
6
6
  queueFactFind,
7
7
  type ExtractionContext,
8
8
  } from "../../core/orchestrators/human-extraction.js";
9
+ import {
10
+ queuePersonRewritePhase,
11
+ queueTopicRewritePhase,
12
+ } from "../../core/orchestrators/ceremony.js";
9
13
 
10
14
  export interface PersonaHistoryImportResult {
11
15
  daysQueued: number;
@@ -141,6 +145,11 @@ export async function importPersonaHistory(
141
145
 
142
146
  result.daysQueued = 1;
143
147
 
148
+ if (result.scansQueued > 0) {
149
+ queuePersonRewritePhase(stateManager);
150
+ queueTopicRewritePhase(stateManager);
151
+ }
152
+
144
153
  const isLastDay = currentDate >= today;
145
154
  advanceProgress(stateManager, currentDate, isLastDay);
146
155
 
@@ -45,7 +45,7 @@ Rules:
45
45
  - Be specific: "React performance patterns" beats "technical stuff"
46
46
  - If the record is clean — everything in it passes the test — return an empty array
47
47
 
48
- Return a raw JSON array of strings. No markdown fencing, no commentary.
48
+ Return a raw JSON array of strings. No markdown fencing, no commentary. Thinking text WILL break the parser.
49
49
 
50
50
  Example — a Person named "Nicholas" whose description includes sprint ticket numbers:
51
51
  ["CMIDP sprint ticket assignments", "ASU Data Lake access provisioning details"]`;
@@ -60,7 +60,7 @@ Example — a Person named "Nicholas" whose description includes sprint ticket n
60
60
 
61
61
  ---
62
62
 
63
- Return a raw JSON array of subject phrases found in this Person record that don't belong there. Return [] if the record is clean.`;
63
+ Return a raw JSON array of subject phrases found in this Person record that don't belong there. Return [] if the record is clean. Thinking text WILL break the parser.`;
64
64
 
65
65
  return { system, user };
66
66
  }
@@ -35,7 +35,7 @@ Rules:
35
35
  - Be specific: "TypeScript coding conventions" beats "technical preferences"
36
36
  - If the record is cohesive and on-topic despite its length, return an empty array
37
37
  ${technicalGuidance}
38
- Return a raw JSON array of strings. No markdown fencing, no commentary.
38
+ Return a raw JSON array of strings. No markdown fencing, no commentary. Thinking text WILL break the parser.
39
39
 
40
40
  Example — a Topic named "Software Engineering" whose description also discusses vim keybindings, git conventions, and AI tooling:
41
41
  ["vim keybindings and editor configuration", "git and GitHub workflow conventions", "AI coding assistant preferences"]`;
@@ -51,7 +51,7 @@ Example — a Topic named "Software Engineering" whose description also discusse
51
51
  ]
52
52
  \`\`\`
53
53
 
54
- Respond with raw JSON array only.`;
54
+ Respond with raw JSON array only. Thinking text WILL break the parser.`;
55
55
 
56
56
  const user = `${payload}
57
57
 
@@ -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
  /**
@@ -79,3 +79,6 @@ export type {
79
79
  RoomHistoryMessage,
80
80
  RoomJudgeCandidate,
81
81
  } from "./room/types.js";
82
+
83
+ export { buildSynthesisPrompt } from "./synthesis/index.js";
84
+ export type { SynthesisPromptData } from "./synthesis/types.js";