ei-tui 0.3.9 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.3.9",
3
+ "version": "0.4.1",
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
 
@@ -2,6 +2,7 @@ import { StateManager } from "../state-manager.js";
2
2
  import { LLMRequestType, LLMPriority, LLMNextStep, type DataItemBase } from "../types.js";
3
3
  import type { DataItemType } from "../types/data-items.js";
4
4
  import { buildDedupPrompt } from "../../prompts/ceremony/dedup.js";
5
+ import { buildUserDedupPrompt } from "../../prompts/ceremony/user-dedup.js";
5
6
 
6
7
  // =============================================================================
7
8
  // TYPES
@@ -201,3 +202,48 @@ export function queueDedupPhase(state: StateManager): void {
201
202
 
202
203
  console.log(`[Dedup] Queued ${totalClusters} clusters for curation`);
203
204
  }
205
+
206
+ // =============================================================================
207
+ // USER-TRIGGERED DEDUP
208
+ // =============================================================================
209
+
210
+ export function queueUserDedupRequest(
211
+ state: StateManager,
212
+ itemType: "topic" | "person",
213
+ entityIds: string[]
214
+ ): void {
215
+ const human = state.getHuman();
216
+
217
+ const collection = (itemType === "topic" ? human.topics : human.people) as DedupableItem[];
218
+ const entities = entityIds
219
+ .map(id => collection.find(item => item.id === id))
220
+ .filter((item): item is DedupableItem => item !== undefined);
221
+
222
+ if (entities.length < 2) {
223
+ console.warn("[UserDedup] Need at least 2 entities to merge");
224
+ return;
225
+ }
226
+
227
+ const prompt = buildUserDedupPrompt({
228
+ cluster: entities,
229
+ itemType,
230
+ similarityRange: { min: 1.0, max: 1.0 },
231
+ });
232
+
233
+ const model = human.settings?.rewrite_model ?? undefined;
234
+
235
+ state.queue_enqueue({
236
+ type: LLMRequestType.JSON,
237
+ priority: LLMPriority.High,
238
+ system: prompt.system,
239
+ user: prompt.user,
240
+ next_step: LLMNextStep.HandleDedupCurate,
241
+ ...(model ? { model } : {}),
242
+ data: {
243
+ entity_type: itemType,
244
+ entity_ids: entityIds,
245
+ },
246
+ });
247
+
248
+ console.log(`[UserDedup] Queued merge of ${entities.length} ${itemType} entities`);
249
+ }
@@ -22,7 +22,7 @@ export {
22
22
  queueDescriptionCheck,
23
23
  runHumanCeremony,
24
24
  } from "./ceremony.js";
25
- export { queueDedupPhase } from "./dedup-phase.js";
25
+ export { queueDedupPhase, queueUserDedupRequest } from "./dedup-phase.js";
26
26
  export {
27
27
  queuePersonaTopicScan,
28
28
  queuePersonaTopicMatch,
@@ -19,7 +19,14 @@ function resolveCanonicalAgent(agentName: string): { canonical: string; aliases:
19
19
  return { canonical, aliases: variants };
20
20
  }
21
21
  }
22
- return { canonical: agentName, aliases: [agentName] };
22
+
23
+ let name = agentName;
24
+ name = name.replace(/^ai-sdlc[:-]/, "");
25
+ name = name.replace(/\s*\([^)]+\)\s*$/, "").trim();
26
+ name = name.replace(/-/g, " ");
27
+ const canonical = name.replace(/\b\w/g, (c) => c.toUpperCase());
28
+
29
+ return { canonical, aliases: [agentName] };
23
30
  }
24
31
 
25
32
  export async function ensureAgentPersona(
@@ -31,7 +31,7 @@ import { ContextStatus as ContextStatusEnum } from "./types.js";
31
31
  import { registerReadMemoryExecutor, registerFileReadExecutor } from "./tools/index.js";
32
32
  import { createReadMemoryExecutor } from "./tools/builtin/read-memory.js";
33
33
  import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.js";
34
- import { shouldStartCeremony, startCeremony, handleCeremonyProgress } from "./orchestrators/index.js";
34
+ import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueUserDedupRequest } from "./orchestrators/index.js";
35
35
  import { BUILT_IN_FACTS } from "./constants/built-in-facts.js";
36
36
 
37
37
  // Static module imports
@@ -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
  }
@@ -1578,6 +1578,10 @@ const toolNextSteps = new Set([
1578
1578
  return clearQueue(this.stateManager, this.queueProcessor);
1579
1579
  }
1580
1580
 
1581
+ queueUserDedup(itemType: "topic" | "person", entityIds: string[]): void {
1582
+ queueUserDedupRequest(this.stateManager, itemType, entityIds);
1583
+ }
1584
+
1581
1585
  async submitOneShot(guid: string, systemPrompt: string, userPrompt: string): Promise<void> {
1582
1586
  return submitOneShot(
1583
1587
  this.stateManager,
@@ -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
@@ -11,6 +11,7 @@ import {
11
11
  queueAllScans,
12
12
  type ExtractionContext,
13
13
  } from "../../core/orchestrators/human-extraction.js";
14
+ import { isProcessRunning } from "../process-check.js";
14
15
 
15
16
  // =============================================================================
16
17
  // Export Types
@@ -219,6 +220,7 @@ export async function importClaudeCodeSessions(
219
220
  const settings = human.settings?.claudeCode;
220
221
  const processedSessions = settings?.processed_sessions ?? {};
221
222
  const now = Date.now();
223
+ const toolRunning = await isProcessRunning("claude");
222
224
 
223
225
  let targetSession: ClaudeCodeSession | null = null;
224
226
 
@@ -228,7 +230,7 @@ export async function importClaudeCodeSessions(
228
230
  const sessionLastMs = new Date(session.lastMessageAt).getTime();
229
231
  const ageMs = now - sessionLastMs;
230
232
 
231
- if (ageMs < MIN_SESSION_AGE_MS) continue; // too fresh
233
+ if (ageMs < MIN_SESSION_AGE_MS && toolRunning) continue;
232
234
 
233
235
  if (!lastImported) {
234
236
  targetSession = session;
@@ -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
@@ -7,6 +7,7 @@ import {
7
7
  MIN_SESSION_AGE_MS,
8
8
  } from "./types.js";
9
9
  import { CursorReader } from "./reader.js";
10
+ import { isProcessRunning } from "../process-check.js";
10
11
  import {
11
12
  queueAllScans,
12
13
  type ExtractionContext,
@@ -184,6 +185,7 @@ export async function importCursorSessions(
184
185
  const human = stateManager.getHuman();
185
186
  const processedSessions = human.settings?.cursor?.processed_sessions ?? {};
186
187
  const now = Date.now();
188
+ const toolRunning = await isProcessRunning("Cursor");
187
189
 
188
190
  let targetSession: CursorSession | null = null;
189
191
 
@@ -191,7 +193,7 @@ export async function importCursorSessions(
191
193
  const sessionLastMs = new Date(session.lastMessageAt).getTime();
192
194
  const ageMs = now - sessionLastMs;
193
195
 
194
- if (ageMs < MIN_SESSION_AGE_MS) continue;
196
+ if (ageMs < MIN_SESSION_AGE_MS && toolRunning) continue;
195
197
 
196
198
  const lastImported = processedSessions[session.id];
197
199
  if (lastImported && sessionLastMs <= new Date(lastImported).getTime()) continue;
@@ -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
@@ -8,6 +8,7 @@ import {
8
8
  queueAllScans,
9
9
  type ExtractionContext,
10
10
  } from "../../core/orchestrators/human-extraction.js";
11
+ import { isProcessRunning } from "../process-check.js";
11
12
 
12
13
  // =============================================================================
13
14
  // Constants
@@ -125,19 +126,20 @@ export async function importOpenCodeSessions(
125
126
  let targetSession: OpenCodeSession | null = null;
126
127
  const MIN_SESSION_AGE_MS = 20 * 60 * 1000; // 20 minutes
127
128
  const now = Date.now();
129
+ const toolRunning = await isProcessRunning("opencode");
128
130
 
129
131
  for (const session of sortedSessions) {
130
132
  const lastImported = processedSessions[session.id];
131
133
  if (!lastImported) {
132
134
  const ageMs = now - session.time.updated;
133
- if (ageMs >= MIN_SESSION_AGE_MS) {
135
+ if (ageMs >= MIN_SESSION_AGE_MS || !toolRunning) {
134
136
  targetSession = session;
135
137
  break;
136
138
  }
137
139
  }
138
140
  if (session.time.updated > new Date(lastImported).getTime()) {
139
141
  const ageMs = now - session.time.updated;
140
- if (ageMs >= MIN_SESSION_AGE_MS) {
142
+ if (ageMs >= MIN_SESSION_AGE_MS || !toolRunning) {
141
143
  targetSession = session;
142
144
  break;
143
145
  }
@@ -175,6 +175,7 @@ export const AGENT_TO_AGENT_PREFIXES = [
175
175
  * Value = array of variants that should resolve to this persona
176
176
  */
177
177
  export const AGENT_ALIASES: Record<string, string[]> = {
178
+ // ── OhMyOpenCode primary agents ──────────────────────────────────────────
178
179
  Sisyphus: [
179
180
  "sisyphus",
180
181
  "Sisyphus",
@@ -182,6 +183,44 @@ export const AGENT_ALIASES: Record<string, string[]> = {
182
183
  "Planner-Sisyphus",
183
184
  "planner-sisyphus",
184
185
  ],
186
+ Build: ["build", "Build"],
187
+ Plan: ["plan", "Plan"],
188
+ Atlas: [
189
+ "atlas",
190
+ "Atlas",
191
+ "atlas (plan executor)",
192
+ "Atlas (plan executor)",
193
+ ],
194
+ Prometheus: [
195
+ "prometheus",
196
+ "Prometheus",
197
+ "prometheus (plan builder)",
198
+ "Prometheus (plan builder)",
199
+ ],
200
+ Hephaestus: [
201
+ "hephaestus",
202
+ "Hephaestus",
203
+ "hephaestus (deep agent)",
204
+ "Hephaestus (deep agent)",
205
+ ],
206
+
207
+ // ── ai-sdlc agents (RobotsAndPencils/ai-sdlc-claude-code-template) ───────
208
+ // Installed via scripts/install-opencode.sh as "ai-sdlc-{name}" in OpenCode.
209
+ // Bare names included for direct/custom installs.
210
+ Architect: ["ai-sdlc-architect", "architect"],
211
+ "Frontend Engineer": ["ai-sdlc-frontend-engineer", "frontend-engineer"],
212
+ "Backend Engineer": ["ai-sdlc-backend-engineer", "backend-engineer"],
213
+ "Code Reviewer": ["ai-sdlc-code-reviewer", "code-reviewer"],
214
+ "Database Engineer": ["ai-sdlc-database-engineer", "database-engineer"],
215
+ Debugger: ["ai-sdlc-debugger", "debugger"],
216
+ "DevOps Engineer": ["ai-sdlc-devops-engineer", "devops-engineer"],
217
+ "Mobile Engineer": ["ai-sdlc-mobile-engineer", "mobile-engineer"],
218
+ "QA Engineer": ["ai-sdlc-qa-engineer", "qa-engineer"],
219
+ "Security Reviewer": ["ai-sdlc-security-reviewer", "security-reviewer"],
220
+ "Spec Validator": ["ai-sdlc-spec-validator", "spec-validator"],
221
+ "Technical Writer": ["ai-sdlc-technical-writer", "technical-writer"],
222
+ "Test Writer": ["ai-sdlc-test-writer", "test-writer"],
223
+ "AI Engineer": ["ai-sdlc-ai-engineer", "ai-engineer"],
185
224
  };
186
225
 
187
226
  /**
@@ -0,0 +1,24 @@
1
+ const isBrowser = typeof document !== "undefined";
2
+
3
+ /**
4
+ * Returns true if any process with the given name is currently running.
5
+ * Always returns true in browser environments (process inspection not available).
6
+ * On Windows uses `tasklist`; on macOS/Linux uses `pgrep -x`.
7
+ */
8
+ export async function isProcessRunning(processName: string): Promise<boolean> {
9
+ if (isBrowser) return true;
10
+ try {
11
+ const CHILD_PROCESS = "child_process";
12
+ const { execSync } = await import(/* @vite-ignore */ CHILD_PROCESS);
13
+ if (process.platform === "win32") {
14
+ const out = execSync(
15
+ `tasklist /FI "IMAGENAME eq ${processName}.exe" /NH 2>NUL`
16
+ ).toString();
17
+ return out.toLowerCase().includes(`${processName.toLowerCase()}.exe`);
18
+ }
19
+ execSync(`pgrep -x ${processName}`, { stdio: "ignore" });
20
+ return true;
21
+ } catch {
22
+ return false;
23
+ }
24
+ }
@@ -3,6 +3,7 @@ export { buildPersonaExplorePrompt } from "./explore.js";
3
3
  export { buildDescriptionCheckPrompt } from "./description-check.js";
4
4
  export { buildRewriteScanPrompt, buildRewritePrompt } from "./rewrite.js";
5
5
  export { buildDedupPrompt } from "./dedup.js";
6
+ export { buildUserDedupPrompt } from "./user-dedup.js";
6
7
  export type {
7
8
  PersonaExpirePromptData,
8
9
  PersonaExpireResult,
@@ -0,0 +1,74 @@
1
+ import type { DedupPromptData } from "./types.js";
2
+
3
+ // =============================================================================
4
+ // USER-TRIGGERED DEDUP — Direct merge, no candidate-finding, no hedging
5
+ // =============================================================================
6
+
7
+ /**
8
+ * Prompt for user-confirmed deduplication.
9
+ *
10
+ * Unlike buildDedupPrompt (ceremony), this skips all decision-making — the user
11
+ * has already confirmed these entities are duplicates. Opus just merges.
12
+ * No hedging, no "our system BELIEVES these MAY be duplicates" language.
13
+ */
14
+ export function buildUserDedupPrompt(data: DedupPromptData): { system: string; user: string } {
15
+ const typeLabel = data.itemType.charAt(0).toUpperCase() + data.itemType.slice(1);
16
+
17
+ const system = `You are merging duplicate ${typeLabel} records in a user's personal knowledge base. The user has manually confirmed that all records in this cluster refer to the same entity.
18
+
19
+ **YOUR PRIME DIRECTIVE: LOSE NO DATA.**
20
+
21
+ Your job is synthesis, not decision-making. Do not question whether these are duplicates — they are. Simply collapse them into one comprehensive, non-repetitive record.
22
+
23
+ ### Merge Rules:
24
+ - Pick the most descriptive, commonly-used name as the canonical name
25
+ - Union all unique details from every description — if it was in any record, it belongs in the merged record
26
+ - Descriptions should be concise (under 300 chars) but complete — no detail left behind
27
+ - Numeric fields: strength/confidence → take HIGHER; sentiment → AVERAGE; exposure → take HIGHER
28
+ - relationship/category → pick most specific/accurate
29
+
30
+ ### Output Format:
31
+ {
32
+ "update": [
33
+ /* The single merged canonical record with ALL fields preserved */
34
+ /* MUST include "id" (use the oldest/most-referenced record's ID), "type", "name", "description" */
35
+ ],
36
+ "remove": [
37
+ {"to_be_removed": "uuid-of-duplicate", "replaced_by": "uuid-of-canonical-record"},
38
+ /* One entry per record being absorbed */
39
+ ],
40
+ "add": []
41
+ }
42
+
43
+ Return raw JSON only. No markdown, no commentary.
44
+
45
+ ${buildRecordFormatHint(data.itemType)}`;
46
+
47
+ const user = JSON.stringify({
48
+ cluster: data.cluster.map(stripEmbedding),
49
+ cluster_type: data.itemType,
50
+ user_confirmed: true,
51
+ }, null, 2);
52
+
53
+ return { system, user };
54
+ }
55
+
56
+ // =============================================================================
57
+ // Helpers
58
+ // =============================================================================
59
+
60
+ function stripEmbedding<T extends { embedding?: unknown }>(item: T): Omit<T, "embedding"> {
61
+ const { embedding: _, ...rest } = item;
62
+ return rest as Omit<T, "embedding">;
63
+ }
64
+
65
+ function buildRecordFormatHint(itemType: string): string {
66
+ switch (itemType) {
67
+ case "person":
68
+ return `Person fields: id, type, name, description, sentiment (-1 to 1), relationship, exposure_current (0-1), exposure_desired (0-1), learned_by (optional), last_changed_by (optional)`;
69
+ case "topic":
70
+ return `Topic fields: id, type, name, description, sentiment (-1 to 1), category, exposure_current (0-1), exposure_desired (0-1), learned_by (optional), last_changed_by (optional)`;
71
+ default:
72
+ return "";
73
+ }
74
+ }
package/tui/README.md CHANGED
@@ -87,6 +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> <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` |
90
91
  | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
91
92
  | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
92
93
  | `/tools` | | Manage tool providers — enable/disable tools per persona |
@@ -0,0 +1,150 @@
1
+ import type { Command } from "./registry.js";
2
+ import { spawnEditor } from "../util/editor.js";
3
+ import type { Topic, Person } from "../../../src/core/types.js";
4
+
5
+ const VALID_TYPES = ["person", "topic"] as const;
6
+ type DedupeType = typeof VALID_TYPES[number];
7
+
8
+ function buildDedupeYAML(type: DedupeType, terms: string[], entities: Array<Topic | Person>): string {
9
+ const termDisplay = terms.map(t => t.includes(" ") ? `"${t}"` : t).join(" | ");
10
+ const header = [
11
+ `# /dedupe ${type} ${terms.map(t => t.includes(" ") ? `"${t}"` : t).join(" ")}`,
12
+ `# Terms: ${termDisplay}`,
13
+ `# Found ${entities.length} match${entities.length === 1 ? "" : "es"}. DELETE blocks for entries to EXCLUDE from the merge.`,
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).`,
15
+ ``,
16
+ ].join("\n");
17
+
18
+ const blocks = entities.map(entity => {
19
+ const lines = [
20
+ `- id: "${entity.id}"`,
21
+ ` name: "${entity.name.replace(/"/g, '\\"')}"`,
22
+ ` description: "${entity.description.replace(/"/g, '\\"')}"`,
23
+ ];
24
+
25
+ if ("relationship" in entity && entity.relationship) {
26
+ lines.push(` relationship: "${entity.relationship}"`);
27
+ }
28
+ if ("category" in entity && entity.category) {
29
+ lines.push(` category: "${entity.category}"`);
30
+ }
31
+ if (entity.learned_by) {
32
+ lines.push(` learned_by: "${entity.learned_by}"`);
33
+ }
34
+
35
+ const date = entity.last_updated ? entity.last_updated.slice(0, 10) : "";
36
+ if (date) lines.push(` last_updated: "${date}"`);
37
+
38
+ return lines.join("\n");
39
+ });
40
+
41
+ return header + blocks.join("\n\n") + "\n";
42
+ }
43
+
44
+ function parseDedupeYAML(content: string): string[] {
45
+ const ids: string[] = [];
46
+ const pattern = /^\s*- id:\s*"([^"]+)"/gm;
47
+ let match: RegExpExecArray | null;
48
+ while ((match = pattern.exec(content)) !== null) {
49
+ ids.push(match[1]);
50
+ }
51
+ return ids;
52
+ }
53
+
54
+ function fuzzySearch(entities: Array<Topic | Person>, query: string): Array<Topic | Person> {
55
+ const lower = query.toLowerCase();
56
+ return entities.filter(entity =>
57
+ entity.name.toLowerCase().includes(lower)
58
+ );
59
+ }
60
+
61
+ export const dedupeCommand: Command = {
62
+ name: "dedupe",
63
+ aliases: [],
64
+ description: "Merge duplicate people or topics",
65
+ usage: '/dedupe <person|topic> <term> ["term 2" ...]',
66
+
67
+ async execute(args, ctx) {
68
+ const type = args[0]?.toLowerCase() as DedupeType | undefined;
69
+
70
+ if (!type || !VALID_TYPES.includes(type)) {
71
+ ctx.showNotification(
72
+ `Usage: /dedupe <person|topic> <term> ["term 2" ...]. Got: ${args[0] ?? "(none)"}`,
73
+ "error"
74
+ );
75
+ return;
76
+ }
77
+
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");
81
+ return;
82
+ }
83
+
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
+
91
+ const pool: Array<Topic | Person> = type === "topic" ? human.topics : human.people;
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
+ }
103
+
104
+ if (matches.length === 0) {
105
+ ctx.showNotification(`No ${type}s matching ${terms.map(t => `"${t}"`).join(" | ")}`, "info");
106
+ return;
107
+ }
108
+
109
+ if (matches.length === 1) {
110
+ ctx.showNotification(`Only 1 ${type} matched — need at least 2 to merge`, "info");
111
+ return;
112
+ }
113
+
114
+ const yamlContent = buildDedupeYAML(type, terms, matches);
115
+
116
+ const result = await spawnEditor({
117
+ initialContent: yamlContent,
118
+ filename: "ei-dedupe.yaml",
119
+ renderer: ctx.renderer,
120
+ });
121
+
122
+ if (result.aborted) {
123
+ ctx.showNotification("Dedupe cancelled", "info");
124
+ return;
125
+ }
126
+
127
+ if (!result.success) {
128
+ ctx.showNotification("Editor failed to open", "error");
129
+ return;
130
+ }
131
+
132
+ if (result.content === null) {
133
+ ctx.showNotification("No changes — dedupe cancelled", "info");
134
+ return;
135
+ }
136
+
137
+ const keptIds = parseDedupeYAML(result.content);
138
+
139
+ if (keptIds.length < 2) {
140
+ ctx.showNotification("Need at least 2 entries to merge — dedupe cancelled", "error");
141
+ return;
142
+ }
143
+
144
+ ctx.ei.queueUserDedup(type, keptIds);
145
+ ctx.showNotification(
146
+ `Queued merge of ${keptIds.length} ${type}s — Opus will synthesize at high priority`,
147
+ "info"
148
+ );
149
+ },
150
+ };
@@ -25,6 +25,7 @@ import { queueCommand } from "../commands/queue";
25
25
  import { dlqCommand } from "../commands/dlq";
26
26
  import { toolsCommand } from "../commands/tools";
27
27
  import { authCommand } from '../commands/auth';
28
+ import { dedupeCommand } from "../commands/dedupe";
28
29
  import { useOverlay } from "../context/overlay";
29
30
  import { CommandSuggest } from "./CommandSuggest";
30
31
  import { useKeyboard } from "@opentui/solid";
@@ -64,6 +65,7 @@ export function PromptInput() {
64
65
  registerCommand(dlqCommand);
65
66
  registerCommand(toolsCommand);
66
67
  registerCommand(authCommand);
68
+ registerCommand(dedupeCommand);
67
69
 
68
70
  let textareaRef: TextareaRenderable | undefined;
69
71
 
@@ -163,7 +165,8 @@ export function PromptInput() {
163
165
  text.startsWith("/context") ||
164
166
  text.startsWith("/messages") ||
165
167
  text === "/queue" ||
166
- text === "/dlq";
168
+ text === "/dlq" ||
169
+ text.startsWith("/dedupe");
167
170
 
168
171
  if (!isEditorCmd && !opensEditorForData) {
169
172
  textareaRef?.clear();
@@ -106,6 +106,7 @@ export interface EiContextValue {
106
106
  getToolList: () => ToolDefinition[];
107
107
  updateToolProvider: (id: string, updates: Partial<Omit<ToolProvider, 'id' | 'created_at'>>) => Promise<boolean>;
108
108
  updateTool: (id: string, updates: Partial<Omit<ToolDefinition, 'id' | 'created_at'>>) => Promise<boolean>;
109
+ queueUserDedup: (itemType: "topic" | "person", entityIds: string[]) => void;
109
110
  cleanupTimers: () => void;
110
111
  }
111
112
  const EiContext = createContext<EiContextValue>();
@@ -141,6 +142,11 @@ export const EiProvider: ParentComponent = (props) => {
141
142
  }, 5000);
142
143
  };
143
144
 
145
+ const queueUserDedup = (itemType: "topic" | "person", entityIds: string[]): void => {
146
+ if (!processor) return;
147
+ processor.queueUserDedup(itemType, entityIds);
148
+ };
149
+
144
150
  const cleanupTimers = () => {
145
151
  if (notificationTimer) {
146
152
  clearTimeout(notificationTimer);
@@ -406,11 +412,13 @@ export const EiProvider: ParentComponent = (props) => {
406
412
  const deleteMessages = async (personaId: string, messageIds: string[]): Promise<void> => {
407
413
  if (!processor) return;
408
414
  await processor.deleteMessages(personaId, messageIds);
415
+ setStore("messages", store.messages.filter(m => !messageIds.includes(m.id)));
409
416
  };
410
417
 
411
418
  const setMessageContextStatus = async (personaId: string, messageId: string, status: ContextStatus): Promise<void> => {
412
419
  if (!processor) return;
413
420
  await processor.setMessageContextStatus(personaId, messageId, status);
421
+ setStore("messages", store.messages.map(m => m.id === messageId ? { ...m, context_status: status } : m));
414
422
  };
415
423
 
416
424
  const recallPendingMessages = async (): Promise<string> => {
@@ -661,6 +669,7 @@ export const EiProvider: ParentComponent = (props) => {
661
669
  getToolList,
662
670
  updateToolProvider,
663
671
  updateTool,
672
+ queueUserDedup,
664
673
  cleanupTimers,
665
674
  };
666
675
  return (
@@ -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
  },