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 +1 -1
- package/src/core/orchestrators/dedup-phase.ts +46 -0
- package/src/core/orchestrators/index.ts +1 -1
- package/src/core/personas/opencode-agent.ts +8 -1
- package/src/core/processor.ts +5 -1
- package/src/integrations/claude-code/importer.ts +3 -1
- package/src/integrations/cursor/importer.ts +3 -1
- package/src/integrations/opencode/importer.ts +4 -2
- package/src/integrations/opencode/types.ts +39 -0
- package/src/integrations/process-check.ts +24 -0
- package/src/prompts/ceremony/index.ts +1 -0
- package/src/prompts/ceremony/user-dedup.ts +74 -0
- package/tui/README.md +1 -0
- package/tui/src/commands/dedupe.tsx +132 -0
- package/tui/src/components/PromptInput.tsx +4 -1
- package/tui/src/context/ei.tsx +7 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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(
|
package/src/core/processor.ts
CHANGED
|
@@ -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;
|
|
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();
|
package/tui/src/context/ei.tsx
CHANGED
|
@@ -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 (
|