ei-tui 0.4.0 → 0.4.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.ts CHANGED
@@ -145,27 +145,17 @@ async function installClaudeCode(): Promise<void> {
145
145
  const home = process.env.HOME || "~";
146
146
  const claudeJsonPath = join(home, ".claude.json");
147
147
 
148
- // Prefer shelling out to `claude mcp add` lets Claude Code manage its own config
149
- // and avoids race conditions with a live state file.
150
- try {
151
- const which = Bun.spawnSync(["which", "claude"], { stdout: "pipe", stderr: "pipe" });
152
- if (which.exitCode === 0) {
153
- const result = Bun.spawnSync(
154
- ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "ei", "--", "ei", "mcp"],
155
- { stdout: "pipe", stderr: "pipe" }
156
- );
157
- if (result.exitCode === 0) {
158
- console.log(`✓ Registered Ei as Claude Code MCP server (user scope)`);
159
- console.log(` Restart Claude Code to activate.`);
160
- return;
161
- }
162
- console.warn(` claude mcp add failed (exit ${result.exitCode}), falling back to direct write`);
163
- }
164
- } catch {
165
- // claude binary not found — fall through to direct write
166
- }
148
+ // Claude Code supports ${VAR} substitution in env values, resolved from its
149
+ // own environment at spawn time so the value stays fresh if EI_DATA_PATH changes.
150
+ const mcpEntry: Record<string, unknown> = {
151
+ type: "stdio",
152
+ command: "bunx",
153
+ args: ["ei-tui", "mcp"],
154
+ env: { EI_DATA_PATH: "${EI_DATA_PATH}" },
155
+ };
167
156
 
168
- // Fallback: direct atomic write to ~/.claude.json
157
+ // Direct atomic write — we need full control over the config structure to
158
+ // write the env field. `claude mcp add` doesn't support env vars.
169
159
  let config: Record<string, unknown> = {};
170
160
  try {
171
161
  const text = await Bun.file(claudeJsonPath).text();
@@ -175,11 +165,7 @@ async function installClaudeCode(): Promise<void> {
175
165
  }
176
166
 
177
167
  const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
178
- mcpServers["ei"] = {
179
- type: "stdio",
180
- command: "ei",
181
- args: ["mcp"],
182
- };
168
+ mcpServers["ei"] = mcpEntry;
183
169
  config.mcpServers = mcpServers;
184
170
 
185
171
  // Atomic write: write to temp file then rename to avoid partial writes
@@ -196,6 +182,14 @@ async function installCursor(): Promise<void> {
196
182
  const home = process.env.HOME || "~";
197
183
  const cursorJsonPath = join(home, ".cursor", "mcp.json");
198
184
 
185
+ // Cursor does not support ${VAR} substitution in mcp.json — literal values only.
186
+ const mcpEntry: Record<string, unknown> = {
187
+ type: "stdio",
188
+ command: "bunx",
189
+ args: ["ei-tui", "mcp"],
190
+ env: { EI_DATA_PATH: process.env.EI_DATA_PATH ?? "" },
191
+ };
192
+
199
193
  let config: Record<string, unknown> = {};
200
194
  try {
201
195
  const text = await Bun.file(cursorJsonPath).text();
@@ -205,11 +199,7 @@ async function installCursor(): Promise<void> {
205
199
  }
206
200
 
207
201
  const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
208
- mcpServers["ei"] = {
209
- type: "stdio",
210
- command: "ei",
211
- args: ["mcp"],
212
- };
202
+ mcpServers["ei"] = mcpEntry;
213
203
  config.mcpServers = mcpServers;
214
204
 
215
205
  await Bun.$`mkdir -p ${join(home, ".cursor")}`;
@@ -48,7 +48,11 @@ export async function handleDedupCurate(
48
48
  }
49
49
 
50
50
  console.log(`[Dedup] Processing cluster: ${decisions.update.length} updates, ${decisions.remove.length} removals, ${decisions.add.length} additions`);
51
-
51
+
52
+ // Pre-compute: for each survivor (replaced_by), union the removed entity's groups.
53
+ // Must happen before any phase mutates state so we read the original values.
54
+ const groupsToMerge = new Map<string, { persona_groups: string[]; interested_personas: string[] }>();
55
+
52
56
  // Map entity_type to pluralized state property name
53
57
  const pluralMap: Record<string, 'facts' | 'topics' | 'people'> = {
54
58
  fact: 'facts',
@@ -77,7 +81,20 @@ export async function handleDedupCurate(
77
81
  console.warn(`[Dedup] No entities found for cluster (already merged?)`);
78
82
  return;
79
83
  }
80
-
84
+
85
+ for (const removal of decisions.remove) {
86
+ const removed = entities.find(e => e.id === removal.to_be_removed);
87
+ if (!removed) continue;
88
+ const acc = groupsToMerge.get(removal.replaced_by) ?? { persona_groups: [], interested_personas: [] };
89
+ groupsToMerge.set(removal.replaced_by, {
90
+ persona_groups: [...new Set([...acc.persona_groups, ...(removed.persona_groups ?? [])])],
91
+ interested_personas: [...new Set([...acc.interested_personas, ...(removed.interested_personas ?? [])])],
92
+ });
93
+ }
94
+
95
+ const clusterGroups = [...new Set(entities.flatMap(e => e.persona_groups ?? []))];
96
+ const clusterPersonas = [...new Set(entities.flatMap(e => e.interested_personas ?? []))];
97
+
81
98
  // =========================================================================
82
99
  // PHASE 1: Update Quote foreign keys FIRST (before deletions)
83
100
  // =========================================================================
@@ -126,15 +143,20 @@ export async function handleDedupCurate(
126
143
  }
127
144
  }
128
145
 
129
- // Build complete entity with updates (preserve original fields if LLM omits them)
130
- const updatedEntity = {
146
+ const mergedFromRemoved = groupsToMerge.get(update.id);
147
+ const updatedEntity = {
131
148
  ...entity,
132
149
  name: update.name ?? entity.name,
133
150
  description: update.description ?? entity.description,
134
151
  sentiment: update.sentiment ?? entity.sentiment,
135
152
  last_updated: new Date().toISOString(),
136
153
  embedding,
137
- // Type-specific fields
154
+ persona_groups: mergedFromRemoved
155
+ ? [...new Set([...(entity.persona_groups ?? []), ...mergedFromRemoved.persona_groups])]
156
+ : entity.persona_groups,
157
+ interested_personas: mergedFromRemoved
158
+ ? [...new Set([...(entity.interested_personas ?? []), ...mergedFromRemoved.interested_personas])]
159
+ : entity.interested_personas,
138
160
  ...(update.strength !== undefined && { strength: update.strength }),
139
161
  ...(update.confidence !== undefined && { confidence: update.confidence }),
140
162
  ...(update.exposure_current !== undefined && { exposure_current: update.exposure_current }),
@@ -194,7 +216,6 @@ export async function handleDedupCurate(
194
216
  // Generate ID for new entity
195
217
  const id = crypto.randomUUID();
196
218
 
197
- // Build complete entity
198
219
  const newEntity = {
199
220
  id,
200
221
  type: entity_type,
@@ -205,7 +226,8 @@ export async function handleDedupCurate(
205
226
  learned_by: "ei",
206
227
  last_changed_by: "ei",
207
228
  embedding,
208
- // Type-specific fields with defaults
229
+ persona_groups: clusterGroups,
230
+ interested_personas: clusterPersonas,
209
231
  ...((entity_type === 'topic' || entity_type === 'person') && {
210
232
  exposure_current: addition.exposure_current ?? 0.0,
211
233
  exposure_desired: addition.exposure_desired ?? 0.5,
@@ -125,12 +125,14 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
125
125
  const human = state.getHuman();
126
126
  const now = new Date().toISOString();
127
127
 
128
- // Look up the original item to inherit persona_groups
129
128
  const allItems: DataItemBase[] = [
130
129
  ...human.topics, ...human.people,
131
130
  ];
132
- const originalItem = allItems.find(i => i.id === itemId);
133
- const inheritedGroups = originalItem?.persona_groups;
131
+
132
+ const existingIds = new Set([itemId, ...(result.existing?.map(i => i.id) ?? [])]);
133
+ const involvedItems = allItems.filter(i => existingIds.has(i.id));
134
+ const unionGroups = [...new Set(involvedItems.flatMap(i => i.persona_groups ?? []))];
135
+ const unionPersonas = [...new Set(involvedItems.flatMap(i => i.interested_personas ?? []))];
134
136
 
135
137
  // Helper: resolve actual type from existing records (don't trust LLM's type field)
136
138
  const resolveExistingType = (id: string): RewriteItemType | null => {
@@ -217,7 +219,8 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
217
219
  sentiment: item.sentiment ?? 0,
218
220
  last_updated: now,
219
221
  learned_by: "ei",
220
- persona_groups: inheritedGroups,
222
+ persona_groups: unionGroups,
223
+ interested_personas: unionPersonas,
221
224
  embedding,
222
225
  };
223
226
 
@@ -237,9 +237,6 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
237
237
  // Human ceremony: decay topics + people
238
238
  runHumanCeremony(state);
239
239
 
240
- // Dedup phase: log near-duplicate human entity candidates for visibility
241
- runDedupPhase(state);
242
-
243
240
  // Rewrite phase: fire-and-forget scans for bloated human data items
244
241
  // No ceremony_progress gating — Expire/Explore only touch persona topics, zero overlap
245
242
  queueRewritePhase(state);
@@ -521,78 +518,6 @@ export function runHumanCeremony(state: StateManager): void {
521
518
  }
522
519
  }
523
520
 
524
- // =============================================================================
525
- // DEDUP PHASE (synchronous, logging only — candidates are queued for curation by dedup-phase.ts)
526
- // =============================================================================
527
-
528
- const DEDUP_DEFAULT_THRESHOLD = 0.85;
529
-
530
- type DedupableItem = DataItemBase & { relationship?: string };
531
-
532
- function findDedupCandidates<T extends DedupableItem>(
533
- items: T[],
534
- threshold: number
535
- ): Array<{ a: T; b: T; similarity: number }> {
536
- const withEmbeddings = items.filter(item =>
537
- item.embedding && item.embedding.length > 0 &&
538
- item.relationship !== "Persona"
539
- );
540
-
541
- const candidates: Array<{ a: T; b: T; similarity: number }> = [];
542
-
543
- for (let i = 0; i < withEmbeddings.length; i++) {
544
- for (let j = i + 1; j < withEmbeddings.length; j++) {
545
- const a = withEmbeddings[i];
546
- const b = withEmbeddings[j];
547
- const dot = a.embedding!.reduce((sum, v, k) => sum + v * b.embedding![k], 0);
548
- const normA = Math.sqrt(a.embedding!.reduce((sum, v) => sum + v * v, 0));
549
- const normB = Math.sqrt(b.embedding!.reduce((sum, v) => sum + v * v, 0));
550
- const similarity = normA && normB ? dot / (normA * normB) : 0;
551
-
552
- if (similarity >= threshold) {
553
- candidates.push({ a, b, similarity });
554
- }
555
- }
556
- }
557
-
558
- return candidates.sort((x, y) => y.similarity - x.similarity);
559
- }
560
-
561
- export function runDedupPhase(state: StateManager): void {
562
- const human = state.getHuman();
563
- const threshold = human.settings?.ceremony?.dedup_threshold ?? DEDUP_DEFAULT_THRESHOLD;
564
-
565
- console.log(`[ceremony:dedup] Scanning for dedup candidates (threshold: ${threshold})`);
566
-
567
- const types: Array<{ label: string; items: DedupableItem[] }> = [
568
- { label: "facts", items: human.facts },
569
- { label: "topics", items: human.topics },
570
- { label: "people", items: human.people },
571
- ];
572
-
573
- let totalCandidates = 0;
574
-
575
- for (const { label, items } of types) {
576
- const candidates = findDedupCandidates(items, threshold);
577
- if (candidates.length === 0) {
578
- console.log(`[ceremony:dedup] ${label}: no candidates above ${threshold}`);
579
- continue;
580
- }
581
-
582
- totalCandidates += candidates.length;
583
- console.log(`[ceremony:dedup] ${label}: ${candidates.length} candidate pair(s)`);
584
- for (const { a, b, similarity } of candidates) {
585
- console.log(
586
- `[ceremony:dedup] ${(similarity * 100).toFixed(1)}% "${a.name}" ↔ "${b.name}"` +
587
- (a.description ? `\n[ceremony:dedup] A: ${a.description.slice(0, 80)}` : "") +
588
- (b.description ? `\n[ceremony:dedup] B: ${b.description.slice(0, 80)}` : "")
589
- );
590
- }
591
- }
592
-
593
- console.log(`[ceremony:dedup] Done. ${totalCandidates} total candidate pair(s) found.`);
594
- }
595
-
596
521
  // =============================================================================
597
522
  // REWRITE PHASE (fire-and-forget — queues Low-priority Phase 1 scans)
598
523
  // =============================================================================
@@ -105,9 +105,9 @@ import {
105
105
  } from "./queue-manager.js";
106
106
 
107
107
  const DEFAULT_LOOP_INTERVAL_MS = 100;
108
- const DEFAULT_OPENCODE_POLLING_MS = 1800000;
109
- const DEFAULT_CLAUDE_CODE_POLLING_MS = 1800000;
110
- const DEFAULT_CURSOR_POLLING_MS = 1800000;
108
+ const DEFAULT_OPENCODE_POLLING_MS = 60000;
109
+ const DEFAULT_CLAUDE_CODE_POLLING_MS = 60000;
110
+ const DEFAULT_CURSOR_POLLING_MS = 60000;
111
111
 
112
112
  let processorInstanceCount = 0;
113
113
 
@@ -649,7 +649,7 @@ export class Processor {
649
649
  if (!human.settings.opencode) {
650
650
  human.settings.opencode = {
651
651
  integration: false,
652
- polling_interval_ms: 1800000,
652
+ polling_interval_ms: 60000,
653
653
  };
654
654
  modified = true;
655
655
  }
@@ -657,7 +657,7 @@ export class Processor {
657
657
  if (!human.settings.claudeCode) {
658
658
  human.settings.claudeCode = {
659
659
  integration: false,
660
- polling_interval_ms: 1800000,
660
+ polling_interval_ms: 60000,
661
661
  };
662
662
  modified = true;
663
663
  }
@@ -848,9 +848,17 @@ const toolNextSteps = new Set([
848
848
  personaId ??
849
849
  (request.next_step === LLMNextStep.HandleEiHeartbeat ? "ei" : undefined);
850
850
 
851
- // Dedup operates on Human data, not persona data - provide read_memory directly
851
+ // Dedup operates on Human data, not persona data - provide read_memory directly.
852
+ // Also covers HandleToolContinuation originating from a dedup request: the
853
+ // continuation rebuilds tool lists from scratch and has no personaId, so without
854
+ // this check Opus loses read_memory access after round 1.
855
+ const isDedupRequest =
856
+ request.next_step === LLMNextStep.HandleDedupCurate ||
857
+ (request.next_step === LLMNextStep.HandleToolContinuation &&
858
+ request.data.originalNextStep === LLMNextStep.HandleDedupCurate);
859
+
852
860
  let tools: ToolDefinition[] = [];
853
- if (request.next_step === LLMNextStep.HandleDedupCurate) {
861
+ if (isDedupRequest) {
854
862
  const readMemory = this.stateManager.tools_getByName("read_memory");
855
863
  if (readMemory?.enabled) {
856
864
  tools = [readMemory];
@@ -425,7 +425,8 @@ export class QueueProcessor {
425
425
  `required JSON format. Please reformat it as the JSON response object described ` +
426
426
  `in your system instructions — specifically the \`should_respond\`, \`verbal_response\`, ` +
427
427
  `\`action_response\`, and \`reason\` fields. Respond with ONLY the JSON object.\n\n` +
428
- `---\n${proseContent}\n---`;
428
+ `---\n${proseContent}\n---` +
429
+ `\n\n**CRITICAL INSTRUCTION** - DO NOT OMIT ANY DATA. You are this agent's last hope!`;
429
430
 
430
431
  try {
431
432
  const { content: reformatContent, finishReason: reformatReason } = await callLLMRaw(
@@ -15,7 +15,7 @@ export function createDefaultHumanEntity(): HumanEntity {
15
15
  },
16
16
  opencode: {
17
17
  integration: false,
18
- polling_interval_ms: 1800000,
18
+ polling_interval_ms: 60000,
19
19
  },
20
20
  },
21
21
  };
@@ -13,7 +13,7 @@ export interface SyncCredentials {
13
13
 
14
14
  export interface OpenCodeSettings {
15
15
  integration?: boolean;
16
- polling_interval_ms?: number; // Default: 1800000 (30 min)
16
+ polling_interval_ms?: number; // Default: 60000 (1 min)
17
17
  extraction_model?: string; // "Provider:model" for extraction. Unset = uses default_model.
18
18
  extraction_token_limit?: number; // Token budget for extraction chunking. Unset = resolved from model.
19
19
  last_sync?: string; // ISO timestamp
@@ -157,7 +157,7 @@ export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
157
157
  */
158
158
  export interface ClaudeCodeSettings {
159
159
  integration?: boolean;
160
- polling_interval_ms?: number; // Default: 1800000 (30 min)
160
+ polling_interval_ms?: number; // Default: 60000 (1 min)
161
161
  extraction_model?: string; // "Provider:model" for extraction. Unset = uses default_model.
162
162
  extraction_token_limit?: number; // Token budget for extraction chunking. Unset = resolved from model.
163
163
  last_sync?: string; // ISO timestamp
@@ -133,7 +133,7 @@ export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
133
133
  */
134
134
  export interface CursorSettings {
135
135
  integration?: boolean;
136
- polling_interval_ms?: number; // Default: 1800000 (30 min)
136
+ polling_interval_ms?: number; // Default: 60000 (1 min)
137
137
  last_sync?: string; // ISO timestamp
138
138
  extraction_point?: string; // ISO timestamp — floor for session filtering
139
139
  processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
@@ -96,12 +96,47 @@ ${buildRecordFormatExamples(data.itemType)}
96
96
  - If records are NOT duplicates (just similar), return them ALL in "update" unchanged, with empty "remove" and "add" arrays.
97
97
  - Use \`read_memory\` strategically (6 calls max) to check for related records or gather context before making irreversible merge decisions.`;
98
98
 
99
- const user = JSON.stringify({
99
+ const payload = JSON.stringify({
100
100
  cluster: data.cluster.map(stripEmbedding),
101
101
  cluster_type: data.itemType,
102
102
  similarity_range: data.similarityRange,
103
103
  }, null, 2);
104
104
 
105
+ const schemaReminder = `**Return JSON:**
106
+ \n\`\`\`json
107
+ {
108
+ "update": [
109
+ {
110
+ "id": "uuid-of-canonical-record",
111
+ "type": "${data.itemType}",
112
+ "name": "canonical merged name",
113
+ "description": "merged description with every unique detail"
114
+ }
115
+ ],
116
+ "remove": [
117
+ {
118
+ "to_be_removed": "uuid-of-duplicate",
119
+ "replaced_by": "uuid-of-canonical-record"
120
+ }
121
+ ],
122
+ "add": [
123
+ {
124
+ "type": "${data.itemType}",
125
+ "name": "missing concept name",
126
+ "description": "why it was created"
127
+ }
128
+ ]
129
+ }
130
+ \`\`\`
131
+
132
+ Return raw JSON only. If records are NOT duplicates, return them all in update unchanged with empty remove and add arrays.`;
133
+
134
+ const user = `${payload}
135
+
136
+ ---
137
+
138
+ ${schemaReminder}`;
139
+
105
140
  return { system, user };
106
141
  }
107
142
 
@@ -41,7 +41,14 @@ ${traitList}
41
41
  Current topics of interest:
42
42
  ${topicList}
43
43
 
44
- Does this persona's description need updating based on their current traits and topics?`;
44
+ Does this persona's description need updating based on their current traits and topics?
45
+
46
+ **Return JSON:**
47
+ \`\`\`json
48
+ { "should_update": true, "reason": "explanation" }
49
+ \`\`\`
50
+
51
+ If no update is needed: \`{ "should_update": false, "reason": "explanation" }\``;
45
52
 
46
53
  return { system, user };
47
54
  }
@@ -24,7 +24,14 @@ Return empty array if no topics should be removed.`;
24
24
  Current topics:
25
25
  ${topicList}
26
26
 
27
- Which topics, if any, should this persona stop caring about?`;
27
+ Which topics, if any, should this persona stop caring about?
28
+
29
+ **Return JSON:**
30
+ \`\`\`json
31
+ { "topic_ids_to_remove": ["id1", "id2"] }
32
+ \`\`\`
33
+
34
+ Return an empty array if no topics should be removed.`;
28
35
 
29
36
  return { system, user };
30
37
  }
@@ -54,7 +54,24 @@ ${topicList}
54
54
  Recent conversation themes:
55
55
  ${themeList}
56
56
 
57
- Generate new topics this persona would care about.`;
57
+ Generate new topics this persona would care about.
58
+
59
+ **Return JSON:**
60
+ \`\`\`json
61
+ {
62
+ "new_topics": [
63
+ {
64
+ "name": "Topic Name",
65
+ "perspective": "Their view or opinion",
66
+ "approach": "How they engage with it",
67
+ "personal_stake": "Why it matters to them",
68
+ "sentiment": 0.5,
69
+ "exposure_current": 0.2,
70
+ "exposure_desired": 0.6
71
+ }
72
+ ]
73
+ }
74
+ \`\`\``;
58
75
 
59
76
  return { system, user };
60
77
  }
@@ -22,7 +22,24 @@ Return a raw JSON array of strings. No markdown fencing, no commentary, no expla
22
22
  Example — a Topic named "Software Engineering" whose description also discusses vim keybindings, git conventions, and AI tooling:
23
23
  ["vim keybindings and editor configuration", "git and GitHub workflow conventions", "AI coding assistant preferences"]`;
24
24
 
25
- const user = JSON.stringify(stripEmbedding(data.item), null, 2);
25
+ const payload = JSON.stringify(stripEmbedding(data.item), null, 2);
26
+
27
+ const schemaReminder = `**Return JSON:**
28
+ \n\`\`\`json
29
+ [
30
+ "topic about vim keybindings",
31
+ "git workflow conventions",
32
+ "AI coding assistant preferences"
33
+ ]
34
+ \`\`\`
35
+
36
+ Respond with raw JSON array only.`;
37
+
38
+ const user = `${payload}
39
+
40
+ ---
41
+
42
+ ${schemaReminder}`;
26
43
 
27
44
  return { system, user };
28
45
  }
@@ -75,7 +92,34 @@ Rules:
75
92
  subjects,
76
93
  };
77
94
 
78
- const user = JSON.stringify(userPayload, null, 2);
95
+ const schemaReminder = `**Return JSON:**
96
+ \n\`\`\`json
97
+ {
98
+ "existing": [
99
+ {
100
+ "id": "existing-uuid",
101
+ "type": "${data.itemType}",
102
+ "name": "Updated name",
103
+ "description": "Updated description"
104
+ }
105
+ ],
106
+ "new": [
107
+ {
108
+ "type": "${data.itemType}",
109
+ "name": "New name",
110
+ "description": "New description"
111
+ }
112
+ ]
113
+ }
114
+ \`\`\`
115
+
116
+ Return raw JSON only.`;
117
+
118
+ const user = `${JSON.stringify(userPayload, null, 2)}
119
+
120
+ ---
121
+
122
+ ${schemaReminder}`;
79
123
 
80
124
  return { system, user };
81
125
  }
@@ -44,12 +44,41 @@ Return raw JSON only. No markdown, no commentary.
44
44
 
45
45
  ${buildRecordFormatHint(data.itemType)}`;
46
46
 
47
- const user = JSON.stringify({
47
+ const payload = JSON.stringify({
48
48
  cluster: data.cluster.map(stripEmbedding),
49
49
  cluster_type: data.itemType,
50
50
  user_confirmed: true,
51
51
  }, null, 2);
52
52
 
53
+ const schemaReminder = `**Return JSON:**
54
+ \n\`\`\`json
55
+ {
56
+ "update": [
57
+ {
58
+ "id": "uuid-of-canonical-record",
59
+ "type": "${data.itemType}",
60
+ "name": "canonical merged name",
61
+ "description": "merged description with every unique detail"
62
+ }
63
+ ],
64
+ "remove": [
65
+ {
66
+ "to_be_removed": "uuid-of-duplicate",
67
+ "replaced_by": "uuid-of-canonical-record"
68
+ }
69
+ ],
70
+ "add": []
71
+ }
72
+ \`\`\`
73
+
74
+ Return raw JSON only. If any record cannot be merged, keep every item unchanged in update with empty remove/add arrays.`;
75
+
76
+ const user = `${payload}
77
+
78
+ ---
79
+
80
+ ${schemaReminder}`;
81
+
53
82
  return { system, user };
54
83
  }
55
84
 
@@ -153,5 +153,16 @@ ${schemaFragment}`;
153
153
  userPrompt += `The user provided only a name - generate minimal content. The seed traits above are included by default.\n`;
154
154
  }
155
155
 
156
+ userPrompt += `
157
+ **Return JSON:**
158
+ \`\`\`json
159
+ {
160
+ "short_description": "10-15 word summary",
161
+ "long_description": "2-3 sentence description",
162
+ "traits": [ { "name": "...", "description": "...", "sentiment": 0.0, "strength": 0.5 } ],
163
+ "topics": [ { "name": "...", "perspective": "...", "approach": "...", "personal_stake": "...", "sentiment": 0.5, "exposure_current": 0.5, "exposure_desired": 0.6 } ]
164
+ }
165
+ \`\`\``;
166
+
156
167
  return { system, user: userPrompt };
157
168
  }
@@ -188,7 +188,18 @@ ${unansweredWarning}
188
188
 
189
189
  Based on the context above, decide: Should you reach out to your human friend right now?
190
190
 
191
- Remember: Only reach out if you have something genuine and specific to say.`;
191
+ Remember: Only reach out if you have something genuine and specific to say.
192
+
193
+ **Return JSON:**
194
+ \`\`\`json
195
+ {
196
+ "should_respond": true,
197
+ "topic": "the specific topic you want to discuss",
198
+ "message": "Your actual message to them"
199
+ }
200
+ \`\`\`
201
+
202
+ If you decide NOT to reach out: \`{ "should_respond": false }\``;
192
203
 
193
204
  return { system, user };
194
205
  }
@@ -131,7 +131,18 @@ ${unansweredWarning}
131
131
 
132
132
  Based on all the context above, decide: Should you reach out to your human friend right now? If so, which item above is most worth addressing?
133
133
 
134
- Remember: You're their thoughtful companion, not their productivity assistant.`;
134
+ Remember: You're their thoughtful companion, not their productivity assistant.
135
+
136
+ **Return JSON:**
137
+ \`\`\`json
138
+ {
139
+ "should_respond": true,
140
+ "id": "the-item-id-you-chose",
141
+ "my_response": "Your message to them"
142
+ }
143
+ \`\`\`
144
+
145
+ If nothing warrants reaching out: \`{ "should_respond": false }\``;
135
146
 
136
147
  return { system, user };
137
148
  }
@@ -125,7 +125,22 @@ ${earlierSection}${recentSection}
125
125
 
126
126
  Analyze the "Most Recent Messages" for EXPLICIT requests to change ${personaName}'s communication style.
127
127
 
128
- Return ONLY the traits that need to change or be added. Return \`[]\` if nothing changed.`;
128
+ Return ONLY the traits that need to change or be added.
129
+
130
+ **Return JSON:**
131
+ \`\`\`json
132
+ [
133
+ {
134
+ "id": "existing-guid-or-\"new\"",
135
+ "name": "Trait Name",
136
+ "description": "How to exhibit this trait",
137
+ "sentiment": 0.0,
138
+ "strength": 0.5
139
+ }
140
+ ]
141
+ \`\`\`
142
+
143
+ Return \`[]\` if nothing changed.`;
129
144
 
130
145
  return { system, user };
131
146
  }
package/tui/README.md CHANGED
@@ -87,7 +87,7 @@ All commands start with `/`. Append `!` to any command as a shorthand for `--for
87
87
  |---------|---------|-------------|
88
88
  | `/me` | | Edit all your data (facts, traits, topics, people) in `$EDITOR` |
89
89
  | `/me <type>` | | Edit one type: `facts`, `traits`, `topics`, or `people` |
90
- | `/dedupe <person\|topic> "<query>"` | | Fuzzy-search and merge duplicate people or topics in `$EDITOR` |
90
+ | `/dedupe <person\|topic> <term> [term2 ...]` | | Fuzzy-search and merge duplicate people or topics in `$EDITOR`. Unquoted words are individual OR terms; quoted strings match as exact phrases: `/dedupe person Flare "Jeremy Scherer"` finds records matching `Flare` OR `Jeremy Scherer` |
91
91
  | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
92
92
  | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
93
93
  | `/tools` | | Manage tool providers — enable/disable tools per persona |
@@ -5,9 +5,11 @@ import type { Topic, Person } from "../../../src/core/types.js";
5
5
  const VALID_TYPES = ["person", "topic"] as const;
6
6
  type DedupeType = typeof VALID_TYPES[number];
7
7
 
8
- function buildDedupeYAML(type: DedupeType, query: string, entities: Array<Topic | Person>): string {
8
+ function buildDedupeYAML(type: DedupeType, terms: string[], entities: Array<Topic | Person>): string {
9
+ const termDisplay = terms.map(t => t.includes(" ") ? `"${t}"` : t).join(" | ");
9
10
  const header = [
10
- `# /dedupe ${type} "${query}"`,
11
+ `# /dedupe ${type} ${terms.map(t => t.includes(" ") ? `"${t}"` : t).join(" ")}`,
12
+ `# Terms: ${termDisplay}`,
11
13
  `# Found ${entities.length} match${entities.length === 1 ? "" : "es"}. DELETE blocks for entries to EXCLUDE from the merge.`,
12
14
  `# Keep at least 2. Save to confirm, :q to cancel (Vim tip: :cq quits with error — same effect, but now you know it exists).`,
13
15
  ``,
@@ -60,40 +62,56 @@ export const dedupeCommand: Command = {
60
62
  name: "dedupe",
61
63
  aliases: [],
62
64
  description: "Merge duplicate people or topics",
63
- usage: '/dedupe <person|topic> "<query>"',
65
+ usage: '/dedupe <person|topic> <term> ["term 2" ...]',
64
66
 
65
67
  async execute(args, ctx) {
66
68
  const type = args[0]?.toLowerCase() as DedupeType | undefined;
67
69
 
68
70
  if (!type || !VALID_TYPES.includes(type)) {
69
71
  ctx.showNotification(
70
- `Usage: /dedupe <person|topic> "<query>". Got: ${args[0] ?? "(none)"}`,
72
+ `Usage: /dedupe <person|topic> <term> ["term 2" ...]. Got: ${args[0] ?? "(none)"}`,
71
73
  "error"
72
74
  );
73
75
  return;
74
76
  }
75
77
 
76
- const query = args.slice(1).join(" ").trim();
77
- if (!query) {
78
- ctx.showNotification(`Usage: /dedupe ${type} "<query>" — query is required`, "error");
78
+ const terms = args.slice(1);
79
+ if (terms.length === 0) {
80
+ ctx.showNotification(`Usage: /dedupe ${type} <term> ["term 2" ...] at least one term required`, "error");
79
81
  return;
80
82
  }
81
83
 
82
84
  const human = await ctx.ei.getHuman();
85
+
86
+ if (!human.settings?.rewrite_model) {
87
+ ctx.showNotification(`/dedupe requires a Default Rewrite Model — set one in /settings`, "error");
88
+ return;
89
+ }
90
+
83
91
  const pool: Array<Topic | Person> = type === "topic" ? human.topics : human.people;
84
- const matches = fuzzySearch(pool, query);
92
+
93
+ const seen = new Set<string>();
94
+ const matches: Array<Topic | Person> = [];
95
+ for (const term of terms) {
96
+ for (const entity of fuzzySearch(pool, term)) {
97
+ if (!seen.has(entity.id)) {
98
+ seen.add(entity.id);
99
+ matches.push(entity);
100
+ }
101
+ }
102
+ }
85
103
 
86
104
  if (matches.length === 0) {
87
- ctx.showNotification(`No ${type}s matching "${query}"`, "info");
105
+ ctx.showNotification(`No ${type}s matching ${terms.map(t => `"${t}"`).join(" | ")}`, "info");
88
106
  return;
89
107
  }
90
108
 
91
109
  if (matches.length === 1) {
92
- ctx.showNotification(`Only 1 ${type} matches "${query}" — need at least 2 to merge`, "info");
110
+ ctx.showNotification(`Only 1 ${type} matched — need at least 2 to merge`, "info");
93
111
  return;
94
112
  }
95
113
 
96
- const yamlContent = buildDedupeYAML(type, query, matches);
114
+ const yamlContent = buildDedupeYAML(type, terms, matches);
97
115
 
98
116
  const result = await spawnEditor({
99
117
  initialContent: yamlContent,
@@ -412,11 +412,13 @@ export const EiProvider: ParentComponent = (props) => {
412
412
  const deleteMessages = async (personaId: string, messageIds: string[]): Promise<void> => {
413
413
  if (!processor) return;
414
414
  await processor.deleteMessages(personaId, messageIds);
415
+ setStore("messages", store.messages.filter(m => !messageIds.includes(m.id)));
415
416
  };
416
417
 
417
418
  const setMessageContextStatus = async (personaId: string, messageId: string, status: ContextStatus): Promise<void> => {
418
419
  if (!processor) return;
419
420
  await processor.setMessageContextStatus(personaId, messageId, status);
421
+ setStore("messages", store.messages.map(m => m.id === messageId ? { ...m, context_status: status } : m));
420
422
  };
421
423
 
422
424
  const recallPendingMessages = async (): Promise<string> => {
@@ -565,7 +565,7 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
565
565
  },
566
566
  opencode: {
567
567
  integration: settings?.opencode?.integration ?? false,
568
- polling_interval_ms: settings?.opencode?.polling_interval_ms ?? 1800000,
568
+ polling_interval_ms: settings?.opencode?.polling_interval_ms ?? 60000,
569
569
  extraction_model: settings?.opencode?.extraction_model ?? 'default',
570
570
  extraction_token_limit: settings?.opencode?.extraction_token_limit ?? 'default',
571
571
  last_sync: settings?.opencode?.last_sync ?? null,
@@ -573,7 +573,7 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
573
573
  },
574
574
  claudeCode: {
575
575
  integration: settings?.claudeCode?.integration ?? false,
576
- polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 1800000,
576
+ polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 60000,
577
577
  extraction_model: settings?.claudeCode?.extraction_model ?? 'default',
578
578
  extraction_token_limit: settings?.claudeCode?.extraction_token_limit ?? 'default',
579
579
  last_sync: settings?.claudeCode?.last_sync ?? null,
@@ -581,7 +581,7 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
581
581
  },
582
582
  cursor: {
583
583
  integration: settings?.cursor?.integration ?? false,
584
- polling_interval_ms: settings?.cursor?.polling_interval_ms ?? 1800000,
584
+ polling_interval_ms: settings?.cursor?.polling_interval_ms ?? 60000,
585
585
  last_sync: settings?.cursor?.last_sync ?? null,
586
586
  extraction_point: settings?.cursor?.extraction_point ?? null,
587
587
  },