ei-tui 0.3.9 → 0.4.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.3.9",
3
+ "version": "0.4.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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
@@ -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,
@@ -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;
@@ -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;
@@ -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> "<query>"` | | Fuzzy-search and merge duplicate people or topics in `$EDITOR` |
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,132 @@
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, query: string, entities: Array<Topic | Person>): string {
9
+ const header = [
10
+ `# /dedupe ${type} "${query}"`,
11
+ `# Found ${entities.length} match${entities.length === 1 ? "" : "es"}. DELETE blocks for entries to EXCLUDE from the merge.`,
12
+ `# 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
+ ``,
14
+ ].join("\n");
15
+
16
+ const blocks = entities.map(entity => {
17
+ const lines = [
18
+ `- id: "${entity.id}"`,
19
+ ` name: "${entity.name.replace(/"/g, '\\"')}"`,
20
+ ` description: "${entity.description.replace(/"/g, '\\"')}"`,
21
+ ];
22
+
23
+ if ("relationship" in entity && entity.relationship) {
24
+ lines.push(` relationship: "${entity.relationship}"`);
25
+ }
26
+ if ("category" in entity && entity.category) {
27
+ lines.push(` category: "${entity.category}"`);
28
+ }
29
+ if (entity.learned_by) {
30
+ lines.push(` learned_by: "${entity.learned_by}"`);
31
+ }
32
+
33
+ const date = entity.last_updated ? entity.last_updated.slice(0, 10) : "";
34
+ if (date) lines.push(` last_updated: "${date}"`);
35
+
36
+ return lines.join("\n");
37
+ });
38
+
39
+ return header + blocks.join("\n\n") + "\n";
40
+ }
41
+
42
+ function parseDedupeYAML(content: string): string[] {
43
+ const ids: string[] = [];
44
+ const pattern = /^\s*- id:\s*"([^"]+)"/gm;
45
+ let match: RegExpExecArray | null;
46
+ while ((match = pattern.exec(content)) !== null) {
47
+ ids.push(match[1]);
48
+ }
49
+ return ids;
50
+ }
51
+
52
+ function fuzzySearch(entities: Array<Topic | Person>, query: string): Array<Topic | Person> {
53
+ const lower = query.toLowerCase();
54
+ return entities.filter(entity =>
55
+ entity.name.toLowerCase().includes(lower)
56
+ );
57
+ }
58
+
59
+ export const dedupeCommand: Command = {
60
+ name: "dedupe",
61
+ aliases: [],
62
+ description: "Merge duplicate people or topics",
63
+ usage: '/dedupe <person|topic> "<query>"',
64
+
65
+ async execute(args, ctx) {
66
+ const type = args[0]?.toLowerCase() as DedupeType | undefined;
67
+
68
+ if (!type || !VALID_TYPES.includes(type)) {
69
+ ctx.showNotification(
70
+ `Usage: /dedupe <person|topic> "<query>". Got: ${args[0] ?? "(none)"}`,
71
+ "error"
72
+ );
73
+ return;
74
+ }
75
+
76
+ const query = args.slice(1).join(" ").trim();
77
+ if (!query) {
78
+ ctx.showNotification(`Usage: /dedupe ${type} "<query>" — query is required`, "error");
79
+ return;
80
+ }
81
+
82
+ const human = await ctx.ei.getHuman();
83
+ const pool: Array<Topic | Person> = type === "topic" ? human.topics : human.people;
84
+ const matches = fuzzySearch(pool, query);
85
+
86
+ if (matches.length === 0) {
87
+ ctx.showNotification(`No ${type}s matching "${query}"`, "info");
88
+ return;
89
+ }
90
+
91
+ if (matches.length === 1) {
92
+ ctx.showNotification(`Only 1 ${type} matches "${query}" — need at least 2 to merge`, "info");
93
+ return;
94
+ }
95
+
96
+ const yamlContent = buildDedupeYAML(type, query, matches);
97
+
98
+ const result = await spawnEditor({
99
+ initialContent: yamlContent,
100
+ filename: "ei-dedupe.yaml",
101
+ renderer: ctx.renderer,
102
+ });
103
+
104
+ if (result.aborted) {
105
+ ctx.showNotification("Dedupe cancelled", "info");
106
+ return;
107
+ }
108
+
109
+ if (!result.success) {
110
+ ctx.showNotification("Editor failed to open", "error");
111
+ return;
112
+ }
113
+
114
+ if (result.content === null) {
115
+ ctx.showNotification("No changes — dedupe cancelled", "info");
116
+ return;
117
+ }
118
+
119
+ const keptIds = parseDedupeYAML(result.content);
120
+
121
+ if (keptIds.length < 2) {
122
+ ctx.showNotification("Need at least 2 entries to merge — dedupe cancelled", "error");
123
+ return;
124
+ }
125
+
126
+ ctx.ei.queueUserDedup(type, keptIds);
127
+ ctx.showNotification(
128
+ `Queued merge of ${keptIds.length} ${type}s — Opus will synthesize at high priority`,
129
+ "info"
130
+ );
131
+ },
132
+ };
@@ -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);
@@ -661,6 +667,7 @@ export const EiProvider: ParentComponent = (props) => {
661
667
  getToolList,
662
668
  updateToolProvider,
663
669
  updateTool,
670
+ queueUserDedup,
664
671
  cleanupTimers,
665
672
  };
666
673
  return (