ei-tui 0.1.24 → 0.2.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 (98) hide show
  1. package/README.md +42 -0
  2. package/package.json +1 -1
  3. package/src/README.md +4 -11
  4. package/src/cli/README.md +4 -5
  5. package/src/cli/retrieval.ts +3 -25
  6. package/src/cli.ts +3 -7
  7. package/src/core/AGENTS.md +1 -1
  8. package/src/core/constants/built-in-facts.ts +49 -0
  9. package/src/core/constants/index.ts +1 -0
  10. package/src/core/context-utils.ts +0 -1
  11. package/src/core/embedding-service.ts +8 -0
  12. package/src/core/handlers/dedup.ts +34 -14
  13. package/src/core/handlers/heartbeat.ts +2 -3
  14. package/src/core/handlers/human-extraction.ts +95 -30
  15. package/src/core/handlers/human-matching.ts +326 -248
  16. package/src/core/handlers/index.ts +8 -6
  17. package/src/core/handlers/persona-generation.ts +8 -8
  18. package/src/core/handlers/rewrite.ts +4 -29
  19. package/src/core/handlers/utils.ts +23 -1
  20. package/src/core/heartbeat-manager.ts +2 -4
  21. package/src/core/human-data-manager.ts +5 -27
  22. package/src/core/message-manager.ts +10 -10
  23. package/src/core/orchestrators/ceremony.ts +60 -46
  24. package/src/core/orchestrators/dedup-phase.ts +11 -5
  25. package/src/core/orchestrators/human-extraction.ts +351 -207
  26. package/src/core/orchestrators/index.ts +6 -4
  27. package/src/core/orchestrators/persona-generation.ts +3 -3
  28. package/src/core/processor.ts +113 -22
  29. package/src/core/prompt-context-builder.ts +4 -6
  30. package/src/core/state/human.ts +1 -26
  31. package/src/core/state/personas.ts +2 -2
  32. package/src/core/state-manager.ts +107 -14
  33. package/src/core/tools/builtin/read-memory.ts +7 -8
  34. package/src/core/types/data-items.ts +2 -4
  35. package/src/core/types/entities.ts +6 -4
  36. package/src/core/types/enums.ts +6 -9
  37. package/src/core/types/llm.ts +2 -2
  38. package/src/core/utils/crossFind.ts +2 -5
  39. package/src/core/utils/event-windows.ts +31 -0
  40. package/src/integrations/claude-code/importer.ts +8 -4
  41. package/src/integrations/claude-code/types.ts +2 -0
  42. package/src/integrations/opencode/importer.ts +7 -3
  43. package/src/prompts/AGENTS.md +73 -1
  44. package/src/prompts/ceremony/dedup.ts +41 -7
  45. package/src/prompts/ceremony/rewrite.ts +3 -22
  46. package/src/prompts/ceremony/types.ts +3 -3
  47. package/src/prompts/generation/descriptions.ts +2 -2
  48. package/src/prompts/generation/types.ts +2 -2
  49. package/src/prompts/heartbeat/types.ts +2 -2
  50. package/src/prompts/human/event-scan.ts +122 -0
  51. package/src/prompts/human/fact-find.ts +106 -0
  52. package/src/prompts/human/fact-scan.ts +0 -2
  53. package/src/prompts/human/index.ts +17 -10
  54. package/src/prompts/human/person-match.ts +65 -0
  55. package/src/prompts/human/person-scan.ts +52 -59
  56. package/src/prompts/human/person-update.ts +241 -0
  57. package/src/prompts/human/topic-match.ts +65 -0
  58. package/src/prompts/human/topic-scan.ts +51 -71
  59. package/src/prompts/human/topic-update.ts +295 -0
  60. package/src/prompts/human/types.ts +63 -40
  61. package/src/prompts/index.ts +4 -8
  62. package/src/prompts/persona/topics-update.ts +2 -2
  63. package/src/prompts/persona/traits.ts +2 -2
  64. package/src/prompts/persona/types.ts +3 -3
  65. package/src/prompts/response/index.ts +1 -1
  66. package/src/prompts/response/sections.ts +9 -12
  67. package/src/prompts/response/types.ts +2 -3
  68. package/src/storage/embeddings.ts +1 -1
  69. package/src/storage/index.ts +1 -0
  70. package/src/storage/indexed.ts +174 -0
  71. package/src/storage/merge.ts +67 -2
  72. package/tui/src/app.tsx +7 -5
  73. package/tui/src/commands/archive.tsx +2 -2
  74. package/tui/src/commands/context.tsx +3 -4
  75. package/tui/src/commands/delete.tsx +4 -4
  76. package/tui/src/commands/dlq.ts +3 -4
  77. package/tui/src/commands/help.tsx +1 -1
  78. package/tui/src/commands/me.tsx +8 -18
  79. package/tui/src/commands/persona.tsx +2 -2
  80. package/tui/src/commands/provider.tsx +3 -5
  81. package/tui/src/commands/queue.ts +3 -4
  82. package/tui/src/commands/quotes.tsx +6 -8
  83. package/tui/src/commands/registry.ts +1 -1
  84. package/tui/src/commands/setsync.tsx +2 -2
  85. package/tui/src/commands/settings.tsx +18 -4
  86. package/tui/src/commands/spotify-auth.ts +0 -1
  87. package/tui/src/commands/tools.tsx +4 -5
  88. package/tui/src/context/ei.tsx +5 -14
  89. package/tui/src/context/overlay.tsx +17 -6
  90. package/tui/src/util/editor.ts +22 -11
  91. package/tui/src/util/persona-editor.tsx +6 -8
  92. package/tui/src/util/provider-editor.tsx +6 -8
  93. package/tui/src/util/toolkit-editor.tsx +3 -4
  94. package/tui/src/util/yaml-serializers.ts +48 -33
  95. package/src/cli/commands/traits.ts +0 -25
  96. package/src/prompts/human/item-match.ts +0 -74
  97. package/src/prompts/human/item-update.ts +0 -364
  98. package/src/prompts/human/trait-scan.ts +0 -115
@@ -9,11 +9,6 @@ export enum ContextStatus {
9
9
  Never = "never",
10
10
  }
11
11
 
12
- export enum ValidationLevel {
13
- None = "none", // Fresh data, never acknowledged
14
- Ei = "ei", // Ei mentioned it to user (don't mention again)
15
- Human = "human", // User explicitly confirmed (locked)
16
- }
17
12
  export enum LLMRequestType {
18
13
  Response = "response",
19
14
  JSON = "json",
@@ -30,12 +25,13 @@ export enum LLMNextStep {
30
25
  HandlePersonaResponse = "handlePersonaResponse",
31
26
  HandlePersonaGeneration = "handlePersonaGeneration",
32
27
  HandlePersonaDescriptions = "handlePersonaDescriptions",
33
- HandleHumanFactScan = "handleHumanFactScan",
34
- HandleHumanTraitScan = "handleHumanTraitScan",
28
+ HandleFactFind = "handleFactFind",
35
29
  HandleHumanTopicScan = "handleHumanTopicScan",
36
30
  HandleHumanPersonScan = "handleHumanPersonScan",
37
- HandleHumanItemMatch = "handleHumanItemMatch",
38
- HandleHumanItemUpdate = "handleHumanItemUpdate",
31
+ HandleTopicMatch = "handleTopicMatch",
32
+ HandleTopicUpdate = "handleTopicUpdate",
33
+ HandlePersonMatch = "handlePersonMatch",
34
+ HandlePersonUpdate = "handlePersonUpdate",
39
35
  HandlePersonaTraitExtraction = "handlePersonaTraitExtraction",
40
36
  HandlePersonaTopicScan = "handlePersonaTopicScan",
41
37
  HandlePersonaTopicMatch = "handlePersonaTopicMatch",
@@ -54,6 +50,7 @@ export enum LLMNextStep {
54
50
  HandleRewriteScan = "handleRewriteScan",
55
51
  HandleRewriteRewrite = "handleRewriteRewrite",
56
52
  HandleDedupCurate = "handleDedupCurate",
53
+ HandleEventScan = "handleEventScan",
57
54
  }
58
55
 
59
56
  export enum ProviderType {
@@ -18,9 +18,9 @@ export interface Message {
18
18
  // Extraction completion flags (omit when false to save space)
19
19
  // Single-letter names minimize storage overhead for large message histories
20
20
  f?: boolean; // Fact extraction completed
21
- r?: boolean; // tRait extraction completed
21
+ t?: boolean; // Topic extraction completed
22
22
  p?: boolean; // Person extraction completed
23
- o?: boolean; // tOpic extraction completed
23
+ e?: boolean; // Event (epic) extraction completed
24
24
  // Image generation fields (web-only, ephemeral)
25
25
  _synthesis?: boolean; // True if message was created by multi-message synthesis
26
26
 
@@ -1,13 +1,12 @@
1
- import type { HumanEntity, PersonaEntity, Fact, Trait, Topic, Person, Quote, PersonaTopic } from "../types.ts";
1
+ import type { HumanEntity, PersonaEntity, Fact, PersonaTrait, Topic, Person, Quote, PersonaTopic } from "../types.ts";
2
2
  export type CrossFindResult =
3
3
  | { type: "fact" } & Fact
4
- | { type: "trait" } & Trait
5
4
  | { type: "topic" } & Topic
6
5
  | { type: "person" } & Person
7
6
  | { type: "quote" } & Quote
8
7
  | { type: "persona" } & PersonaEntity
9
8
  | { type: "personaTopic"; personaId: string } & PersonaTopic
10
- | { type: "personaTrait"; personaId: string } & Trait;
9
+ | { type: "personaTrait"; personaId: string } & PersonaTrait;
11
10
 
12
11
  export function crossFind(
13
12
  id: string,
@@ -18,8 +17,6 @@ export function crossFind(
18
17
  const fact = human.facts.find(f => f.id === id);
19
18
  if (fact) return { type: "fact", ...fact };
20
19
 
21
- const trait = human.traits.find(t => t.id === id);
22
- if (trait) return { type: "trait", ...trait };
23
20
 
24
21
  const person = human.people.find(p => p.id === id);
25
22
  if (person) return { type: "person", ...person };
@@ -0,0 +1,31 @@
1
+ import type { Message } from "../types.js";
2
+
3
+ const DEFAULT_EVENT_WINDOW_HOURS = 8;
4
+
5
+ export function buildEventWindows(
6
+ messages: Message[],
7
+ gapHours: number = DEFAULT_EVENT_WINDOW_HOURS
8
+ ): Message[][] {
9
+ if (messages.length === 0) return [];
10
+
11
+ const gapMs = gapHours * 60 * 60 * 1000;
12
+ const windows: Message[][] = [];
13
+ let currentWindow: Message[] = [messages[0]];
14
+
15
+ for (let i = 1; i < messages.length; i++) {
16
+ const prev = new Date(messages[i - 1].timestamp).getTime();
17
+ const curr = new Date(messages[i].timestamp).getTime();
18
+
19
+ if (curr - prev >= gapMs) {
20
+ windows.push(currentWindow);
21
+ currentWindow = [];
22
+ }
23
+ currentWindow.push(messages[i]);
24
+ }
25
+
26
+ if (currentWindow.length > 0) {
27
+ windows.push(currentWindow);
28
+ }
29
+
30
+ return windows;
31
+ }
@@ -54,9 +54,9 @@ function convertToPreMarkedEiMessage(msg: ClaudeCodeMessage): Message {
54
54
  return {
55
55
  ...convertToEiMessage(msg),
56
56
  f: true,
57
- r: true,
57
+ t: true,
58
58
  p: true,
59
- o: true,
59
+ e: true,
60
60
  };
61
61
  }
62
62
 
@@ -130,7 +130,7 @@ function ensureSessionTopic(
130
130
  exposure_current: 0.5,
131
131
  exposure_desired: 0.3,
132
132
  persona_groups: CLAUDE_CODE_TOPIC_GROUPS,
133
- learned_by: CLAUDE_CODE_PERSONA_NAME,
133
+ learned_by: stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME)?.id ?? undefined,
134
134
  last_updated: new Date().toISOString(),
135
135
  };
136
136
 
@@ -305,7 +305,11 @@ export async function importClaudeCodeSessions(
305
305
  messages_analyze: toAnalyze,
306
306
  };
307
307
 
308
- queueAllScans(context, stateManager);
308
+ const ccSettings = stateManager.getHuman().settings?.claudeCode;
309
+ queueAllScans(context, stateManager, {
310
+ extraction_model: ccSettings?.extraction_model,
311
+ extraction_token_limit: ccSettings?.extraction_token_limit,
312
+ });
309
313
  result.extractionScansQueued += 4;
310
314
  }
311
315
 
@@ -158,6 +158,8 @@ export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
158
158
  export interface ClaudeCodeSettings {
159
159
  integration?: boolean;
160
160
  polling_interval_ms?: number; // Default: 1800000 (30 min)
161
+ extraction_model?: string; // "Provider:model" for extraction. Unset = uses default_model.
162
+ extraction_token_limit?: number; // Token budget for extraction chunking. Unset = resolved from model.
161
163
  last_sync?: string; // ISO timestamp
162
164
  processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
163
165
  }
@@ -57,9 +57,9 @@ function convertToPreMarkedEiMessage(ocMsg: OpenCodeMessage): Message {
57
57
  return {
58
58
  ...convertToEiMessage(ocMsg),
59
59
  f: true,
60
- r: true,
60
+ t: true,
61
61
  p: true,
62
- o: true,
62
+ e: true,
63
63
  };
64
64
  }
65
65
 
@@ -229,7 +229,11 @@ export async function importOpenCodeSessions(
229
229
  };
230
230
 
231
231
  if (!signal?.aborted) {
232
- queueAllScans(context, stateManager);
232
+ const openCodeSettings = stateManager.getHuman().settings?.opencode;
233
+ queueAllScans(context, stateManager, {
234
+ extraction_model: openCodeSettings?.extraction_model,
235
+ extraction_token_limit: openCodeSettings?.extraction_token_limit,
236
+ });
233
237
  result.extractionScansQueued += 4;
234
238
  }
235
239
  }
@@ -29,7 +29,7 @@ interface PromptBuilder<T> {
29
29
 
30
30
  **Rules**:
31
31
  1. **Synchronous** - No async, no fetching
32
- 2. **Pure** - Same input → same output
32
+ 2. **Pure** - Same input → same output. No state reads, no side effects.
33
33
  3. **Pre-processed data** - Processor fetches/filters before calling
34
34
  4. **Minimal logic** - String interpolation, not computation
35
35
 
@@ -60,3 +60,75 @@ interface PromptBuilder<T> {
60
60
  **Prompt engineering lives here. Code logic lives in Processor.**
61
61
 
62
62
  When modifying persona behavior, check prompts first—the "personality" is in the English, not the TypeScript.
63
+
64
+ ---
65
+
66
+ ## VIOLATIONS
67
+
68
+ These are wrong. If you see them, fix them.
69
+
70
+ ### Prompt strings defined outside `src/prompts/`
71
+
72
+ **Violation:**
73
+ ```typescript
74
+ // ❌ In src/core/handlers/something.ts
75
+ const system = `You are an expert at JSON. Return only valid JSON with no commentary.`;
76
+ const user = `Fix this broken JSON: ${badJson}`;
77
+ const response = await llmClient.call({ system, user });
78
+ ```
79
+
80
+ **Correct:** Move the prompt to `src/prompts/[purpose]/index.ts`. The handler calls the builder with pre-fetched data; the builder returns `{ system, user }`.
81
+
82
+ ### Exception: JSON recovery prompt in `queue-processor.ts`
83
+
84
+ There is one deliberate exception to the "all prompts live in `src/prompts/`" rule: the JSON repair retry prompt in `queue-processor.ts`.
85
+
86
+ **Why it's an exception**: This is a *repair heuristic* — it fires after a JSON parse failure to ask the LLM to fix its own malformed output. It has no domain knowledge, no persona data, and no human data. It's infrastructure-level error recovery, not a domain prompt. Moving it to `src/prompts/` would create a degenerate prompt builder with a one-line body and no meaningful data contract.
87
+
88
+ **Criteria for a legitimate exception** (all must be true):
89
+ 1. The prompt contains zero domain knowledge (no persona names, no human data, no Ei concepts)
90
+ 2. It's error recovery or infrastructure glue, not business logic
91
+ 3. Moving it to `src/prompts/` would produce a builder with no real `types.ts` (no input data shape worth naming)
92
+
93
+ If your use case doesn't meet all three criteria, it belongs in `src/prompts/`.
94
+
95
+ ### Prompt builders that do computation
96
+
97
+ **Violation:**
98
+ ```typescript
99
+ // ❌ Prompt builder filtering its own data
100
+ function buildResponsePrompt(data: ResponsePromptData) {
101
+ const relevantFacts = data.facts.filter(f => f.sentiment > 0.5); // ← WRONG
102
+ const recentTopics = data.topics.slice(-5); // ← WRONG
103
+ return { system: `...${relevantFacts}...`, user: `...` };
104
+ }
105
+ ```
106
+
107
+ **Correct:** The Processor filters and slices before calling the builder. The builder receives already-filtered data and does string interpolation only.
108
+
109
+ > If you're writing a loop, a `.filter()`, or a `.map()` inside a prompt builder for anything other than formatting a string — stop. That logic belongs in the Processor or a pre-processing utility.
110
+
111
+ ### Async prompt builders
112
+
113
+ **Violation:**
114
+ ```typescript
115
+ // ❌ Async prompt builder
116
+ async function buildHeartbeatPrompt(personaId: string) {
117
+ const persona = await stateManager.getPersona(personaId); // ← WRONG
118
+ return { system: `...`, user: `...` };
119
+ }
120
+ ```
121
+
122
+ **Correct:** Processor calls `stateManager.getPersona()` first, then passes the result to the synchronous builder.
123
+
124
+ ---
125
+
126
+ ## Prompt Creep Warning
127
+
128
+ Prompt builders are the most-edited files in the codebase. Watch for these failure modes:
129
+
130
+ - **Logic creep**: A builder that started as pure string interpolation slowly accumulates conditionals, date math, or data filtering. Each addition seems small. After six changes it's a 200-line function that requires mocking to test. If you're adding branching logic to a prompt builder — reconsider. Move it to the Processor.
131
+
132
+ - **Data shape expansion**: A builder's input type grows to include things it doesn't actually use in the prompt string. This means the Processor is fetching data the prompt doesn't need. Audit `types.ts` when the data shape grows.
133
+
134
+ - **Responsibility leakage**: A prompt builder that calls another prompt builder, or calls a utility that reads from state, or has a side effect on logging that depends on runtime context. Builders must be standalone: same input, same output, every time, in any order.
@@ -15,22 +15,56 @@ import type { DedupPromptData } from "./types.js";
15
15
  export function buildDedupPrompt(data: DedupPromptData): { system: string; user: string } {
16
16
  const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
17
17
 
18
- const system = `You are acting as the curator for a user's internal database. You have been given a cluster of ${typeLabel} records that our system believes may be duplicates (based on semantic similarity >= 0.90).
18
+ const system = `## HARD RULES (Non-Negotiable Override All Other Instructions)
19
+
20
+ You are working with Opus 4.6 constraints. These rules prevent overthinking and ensure decisive action:
21
+
22
+ ### 1. TOOL BUDGET
23
+ - You have **6 \`read_memory\` calls** for this cluster
24
+ - Prioritize: verify ambiguous relationships > check parent concepts > validate new entities
25
+ - After 6 calls, make decisions with available information
26
+ - Do NOT waste calls re-checking pairs you already examined
27
+
28
+ ### 2. SATISFICING MODE (Good Enough > Perfect)
29
+ - If two items share **85%+ semantic similarity** on core meaning → merge them
30
+ - Do NOT re-examine after deciding to merge
31
+ - Do NOT explore alternative groupings
32
+ - First valid match wins — stop searching for "better" options
33
+
34
+ ### 3. FORBIDDEN PATTERNS (Signs of Overthinking)
35
+ If you find yourself writing these phrases, **STOP IMMEDIATELY**:
36
+ - ❌ "On the other hand..." / "However, there's another angle..."
37
+ - ❌ "Let me reconsider..." / "But what if..."
38
+ - ❌ "This could be interpreted as..."
39
+ - ❌ Re-analyzing the same pair after making a decision
40
+
41
+ Output format when you catch overthinking:
42
+ \`\`\`
43
+ [OVERTHINKING DETECTED]
44
+ Decision: [Yes/No to merge]
45
+ Reason: [1 sentence]
46
+ \`\`\`
47
+
48
+ ---
49
+
50
+ ## YOUR TASK
51
+
52
+ You are acting as the curator for a user's internal database. You have been given a cluster of ${typeLabel} records that our system believes may be duplicates (based on semantic similarity >= 0.90).
19
53
 
20
54
  **YOUR PRIME DIRECTIVE IS TO LOSE _NO_ DATA.**
21
55
 
22
56
  Your secondary directive is to ORGANIZE IT into small, non-repetitive components. The user NEEDS the data, but the data is used by AI agents, so duplication limits usefulness—agents waste tokens re-reading the same information under different names.
23
57
 
24
- You have access to a tool called \`read_memory\` which will query the user's internal system for additional context if needed. Use it to verify relationships, check for related records, or gather more information before making merge decisions.
58
+ You have access to a tool called \`read_memory\` (6 calls max see HARD RULES above). Use it strategically to verify relationships, check for related records, or gather context before making merge decisions.
25
59
 
26
- Your task:
27
- 1. **Identify true duplicates**: Examine each record. Are these genuinely the same thing with different wording, or are they distinct but related concepts?
60
+ ### Decision Process:
61
+ 1. **Identify true duplicates**: Examine each record. Are these genuinely the same thing with different wording (85%+ core meaning overlap), or are they distinct but related concepts?
28
62
  2. **Merge where appropriate**: For TRUE duplicates, consolidate all unique information into ONE canonical record. Pick the best "name" (most descriptive, most commonly used). Merge all descriptions—every unique detail must be preserved.
29
63
  3. **Keep distinct concepts separate**: Similar ≠ duplicate. "Software Engineering" and "Software Architecture" may be related but are NOT the same. "Job at Company X" and "Profession: Software Engineer" are related but distinct. Do NOT merge these.
30
64
  4. **Track what was merged**: For removed records, indicate which record absorbed their data (via "replaced_by" field).
31
65
  5. **Add new records if needed**: If consolidating reveals a MISSING intermediate concept (e.g., merging "Python Developer" and "Backend Engineer" reveals we're missing "Software Engineering" as a parent topic), create it.
32
66
 
33
- The format of your final output should be:
67
+ ### Output Format:
34
68
  {
35
69
  "update": [
36
70
  /* Full ${typeLabel} record payloads with all fields preserved */
@@ -53,14 +87,14 @@ Record format for "${typeLabel}" (based on type):
53
87
 
54
88
  ${buildRecordFormatExamples(data.itemType)}
55
89
 
56
- Rules:
90
+ ### Rules:
57
91
  - Do NOT invent information. Only redistribute what exists in the cluster.
58
92
  - Descriptions should be concise—ideally under 300 characters, never over 500.
59
93
  - Preserve all numeric values (sentiment, strength, confidence, exposure, etc.) from source records. When merging, take the HIGHER value for strength/confidence, AVERAGE for sentiment.
60
94
  - Every removed record MUST have "replaced_by" pointing to the canonical record that absorbed its data.
61
95
  - The "update" array should contain AT LEAST ONE record (the canonical/merged one), even if all others are removed.
62
96
  - If records are NOT duplicates (just similar), return them ALL in "update" unchanged, with empty "remove" and "add" arrays.
63
- - Use \`read_memory\` to check for related records or gather context before making irreversible merge decisions.`;
97
+ - Use \`read_memory\` strategically (6 calls max) to check for related records or gather context before making irreversible merge decisions.`;
64
98
 
65
99
  const user = JSON.stringify({
66
100
  cluster: data.cluster.map(stripEmbedding),
@@ -59,10 +59,9 @@ Rules:
59
59
  - The original record (id: "${data.item.id}") MUST appear in "existing", slimmed down.
60
60
  - Descriptions should be concise — ideally under 300 characters, never over 500.
61
61
  - Preserve sentiment, strength, confidence, and other numeric values from the source record where applicable.
62
- - "type" must be one of: "fact", "trait", "topic", "person".
63
- - Topics MUST include "category" — one of: Interest, Goal, Dream, Conflict, Concern, Fear, Hope, Plan, Project.
62
+ - "type" must be one of: "fact", "topic", "person".
63
+ - 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.
64
64
  - People MUST include "relationship" — a short label like "coworker", "friend", "mentor", etc.
65
- - Traits MUST include "strength" (0.0-1.0).
66
65
  - Do NOT invent information. Only redistribute what exists in the original record.`;
67
66
 
68
67
  const subjects = data.subjects.map(s => ({
@@ -100,15 +99,6 @@ function buildExistingExamples(): string {
100
99
  "description": "Updated description with incorporated data"
101
100
  }
102
101
 
103
- Trait:
104
- {
105
- "id": "existing-uuid",
106
- "type": "trait",
107
- "name": "Trait Name",
108
- "description": "Updated trait description",
109
- "strength": 0.7
110
- }
111
-
112
102
  Topic:
113
103
  {
114
104
  "id": "existing-uuid",
@@ -137,22 +127,13 @@ function buildNewExamples(): string {
137
127
  "sentiment": 0.0
138
128
  }
139
129
 
140
- Trait:
141
- {
142
- "type": "trait",
143
- "name": "New Trait Name",
144
- "description": "Concise trait description",
145
- "sentiment": 0.0,
146
- "strength": 0.5
147
- }
148
-
149
130
  Topic:
150
131
  {
151
132
  "type": "topic",
152
133
  "name": "New Topic Name",
153
134
  "description": "Concise topic description",
154
135
  "sentiment": 0.0,
155
- "category": "Interest"
136
+ "category": "Interest|Goal|Dream|Conflict|Concern|Fear|Hope|Plan|Project|Event"
156
137
  }
157
138
 
158
139
  Person:
@@ -1,4 +1,4 @@
1
- import type { Trait, PersonaTopic, DataItemBase } from "../../core/types.js";
1
+ import type { PersonaTrait, PersonaTopic, DataItemBase } from "../../core/types.js";
2
2
 
3
3
  export interface PersonaExpirePromptData {
4
4
  persona_name: string;
@@ -11,7 +11,7 @@ export interface PersonaExpireResult {
11
11
 
12
12
  export interface PersonaExplorePromptData {
13
13
  persona_name: string;
14
- traits: Trait[];
14
+ traits: PersonaTrait[];
15
15
  remaining_topics: PersonaTopic[];
16
16
  recent_conversation_themes: string[];
17
17
  }
@@ -32,7 +32,7 @@ export interface DescriptionCheckPromptData {
32
32
  persona_name: string;
33
33
  current_short_description?: string;
34
34
  current_long_description?: string;
35
- traits: Trait[];
35
+ traits: PersonaTrait[];
36
36
  topics: PersonaTopic[];
37
37
  }
38
38
 
@@ -1,7 +1,7 @@
1
1
  import type { PersonaDescriptionsPromptData, PromptOutput } from "./types.js";
2
- import type { Trait, PersonaTopic } from "../../core/types.js";
2
+ import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
3
3
 
4
- function formatTraitsForPrompt(traits: Trait[]): string {
4
+ function formatTraitsForPrompt(traits: PersonaTrait[]): string {
5
5
  if (traits.length === 0) return "(No traits defined)";
6
6
 
7
7
  return traits.map(t => {
@@ -1,4 +1,4 @@
1
- import type { Trait, PersonaTopic } from "../../core/types.js";
1
+ import type { PersonaTrait, PersonaTopic } from "../../core/types.js";
2
2
 
3
3
  export interface PromptOutput {
4
4
  system: string;
@@ -36,7 +36,7 @@ export interface PersonaGenerationResult {
36
36
  export interface PersonaDescriptionsPromptData {
37
37
  name: string;
38
38
  aliases: string[];
39
- traits: Trait[];
39
+ traits: PersonaTrait[];
40
40
  topics: PersonaTopic[];
41
41
  }
42
42
 
@@ -3,7 +3,7 @@
3
3
  * Based on CONTRACTS.md specifications
4
4
  */
5
5
 
6
- import type { Trait, Topic, Person, Message, PersonaTopic } from "../../core/types.js";
6
+ import type { PersonaTrait, Topic, Person, Message, PersonaTopic } from "../../core/types.js";
7
7
 
8
8
  /**
9
9
  * Common prompt output structure
@@ -19,7 +19,7 @@ export interface PromptOutput {
19
19
  export interface HeartbeatCheckPromptData {
20
20
  persona: {
21
21
  name: string;
22
- traits: Trait[];
22
+ traits: PersonaTrait[];
23
23
  topics: PersonaTopic[];
24
24
  };
25
25
  human: {
@@ -0,0 +1,122 @@
1
+ import type { PromptOutput, ParticipantContext } from "./types.js";
2
+ import type { Message } from "../../core/types.js";
3
+ import { formatMessagesAsPlaceholders } from "../message-utils.js";
4
+
5
+ function participantContextSection(ctx: ParticipantContext | undefined): string {
6
+ if (!ctx) return "";
7
+ const lines: string[] = ["# Participant Context", "The following may help you understand what themes and moments are meaningful in this conversation.", ""];
8
+ lines.push(`## Persona: ${ctx.persona_name}`);
9
+ if (ctx.persona_description) lines.push(ctx.persona_description);
10
+ lines.push("");
11
+ lines.push("## Human");
12
+ if (ctx.human_name) lines.push(`Name: ${ctx.human_name}`);
13
+ if (ctx.human_age !== undefined) lines.push(`Age: ${ctx.human_age}`);
14
+ lines.push("");
15
+ return lines.join("\n");
16
+ }
17
+
18
+ export interface EventScanPromptData {
19
+ persona_name: string;
20
+ messages_context: Message[];
21
+ messages_analyze: Message[];
22
+ window_start?: string;
23
+ window_end?: string;
24
+ participant_context?: ParticipantContext;
25
+ }
26
+
27
+ export function buildEventScanPrompt(data: EventScanPromptData): PromptOutput {
28
+ if (!data.persona_name) {
29
+ throw new Error("buildEventScanPrompt: persona_name is required");
30
+ }
31
+
32
+ const personaName = data.persona_name;
33
+
34
+ const system = `# Task
35
+
36
+ You are scanning a conversation window to identify EPIC EVENTS worth preserving as long-term memories.
37
+
38
+ An EPIC EVENT is a significant, bounded moment — something either participant would reference months later with recognition. Not a topic. Not a theme. A specific thing that happened.
39
+
40
+ ## The Test
41
+
42
+ Ask yourself: "Would this moment get a section heading in a campaign recap document?"
43
+
44
+ - "The Night We Debugged Beta's CPU" → YES
45
+ - "First session with filesystem access" → YES
46
+ - "The time the health check cached the API response forever" → YES
47
+ - "We talked about AI" → NO (that's a Topic) return empty
48
+ - "Flare asked some questions" → NO (too vague) return empty
49
+ - Normal conversation without a notable arc → NO (not epic) return empty
50
+
51
+ ## What Makes an EPIC EVENT
52
+
53
+ - A conflict encountered and (possibly) resolved
54
+ - A discovery or breakthrough moment
55
+ - A memorable failure or unexpected outcome
56
+ - A significant "first" in the relationship
57
+ - A pivotal decision or turning point
58
+ - A moment either party would say "oh THAT session" about
59
+
60
+ ## What Is NOT an EPIC EVENT
61
+
62
+ - Ongoing themes or interests (those are Topics)
63
+ - Casual check-ins without a notable arc
64
+ - Technical facts without a story around them
65
+ - Anything that's already an ongoing trend rather than a bounded moment
66
+
67
+ ## Output Format
68
+
69
+ \`\`\`json
70
+ {
71
+ "events": [
72
+ {
73
+ "name": "Short evocative label (5-50 characters)",
74
+ "description": "1-2 sentences: what happened, why it mattered. Write it like a friend describing the moment to someone who wasn't there.",
75
+ "reason": "Evidence from the conversation — what made this rise to Epic Event level"
76
+ }
77
+ ]
78
+ }
79
+ \`\`\`
80
+
81
+ Return an empty array if nothing qualifies. An empty array is the most common expected response.
82
+
83
+ **Return JSON only. Be conservative. One memorable moment per window is the norm.**
84
+
85
+ ONLY ANALYZE the "Most Recent Messages". The "Earlier Conversation" is provided for context only — it has already been processed.
86
+
87
+ ${participantContextSection(data.participant_context)}`;
88
+
89
+ const earlierSection = data.messages_context.length > 0
90
+ ? `## Earlier Conversation
91
+ ${formatMessagesAsPlaceholders(data.messages_context, personaName)}
92
+
93
+ `
94
+ : '';
95
+
96
+ const recentSection = `## Most Recent Messages
97
+ ${formatMessagesAsPlaceholders(data.messages_analyze, personaName)}`;
98
+
99
+ const user = `# Conversation Window
100
+ ${earlierSection}${recentSection}
101
+
102
+ ---
103
+
104
+ Scan this conversation window for EPIC EVENTS — specific, memorable moments worth preserving long-term.
105
+
106
+ **Return JSON:**
107
+ \`\`\`json
108
+ {
109
+ "events": [
110
+ {
111
+ "name": "Short evocative label",
112
+ "description": "What happened and why it mattered",
113
+ "reason": "Evidence from the conversation"
114
+ }
115
+ ]
116
+ }
117
+ \`\`\`
118
+
119
+ Return empty array if nothing qualifies. Be conservative.`;
120
+
121
+ return { system, user };
122
+ }