ei-tui 0.3.8 → 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/README.md CHANGED
@@ -1,12 +1,12 @@
1
1
  # Ei
2
2
 
3
- A local-first AI companion system with persistent personas and Opencode Integration.
3
+ A local-first AI companion system with persistent personas and coding tool integrations (OpenCode, Claude Code, Cursor).
4
4
 
5
5
  You can access the Web version at [ei.flare576.com](https://ei.flare576.com).
6
6
 
7
7
  You can install the local version via `npm install -g ei-tui` (see [### TUI](#tui) for details).
8
8
 
9
- If you're here to give Opencode perpetual memory (yes), jump over to [TUI README.md](./tui/README.md) to learn how to get information _into_ Ei, and [CLI README.md](./src/cli/README.md) to get it back _out_.
9
+ If you're here to give your coding tools (OpenCode, Claude Code, Cursor) persistent memory, jump over to [TUI README.md](./tui/README.md) to learn how to get information _into_ Ei, and [CLI README.md](./src/cli/README.md) to get it back _out_.
10
10
 
11
11
  ## What Does "Local First" Mean?
12
12
 
@@ -108,13 +108,49 @@ Regardless, Running `ei` pops open the TUI interface and, just like on the web,
108
108
 
109
109
  More information (including commands) can be found in the [TUI Readme](tui/README.md)
110
110
 
111
- ### Opencode
111
+ ### Coding Tool Integrations
112
112
 
113
- Ei gives OpenCode a persistent memory. Yes, this is a dynamic, perpetual RAGI didn't plan it that way, but here we are.
113
+ Ei can import sessions from your coding tools and extract what you've been working on pulling out facts, topics, and context that persist across sessions. Enable any combination; they work independently and feed into the same knowledge base.
114
114
 
115
- Opencode saves all of its sessions locally, either in a JSON structure or, if you're running the latest version, in a SQLite DB. If you enable the integration, Ei will pull all of the conversational parts of those sessions and summarize them, pulling out details, quotes, and keeping the summaries up-to-date.
115
+ All three integrations are enabled via `/settings` in the TUI.
116
116
 
117
- Then, Opencode can call into Ei and pull those details back out. That's why you always have a side-project or two going. See [TUI Readme](tui/README.md)
117
+ #### OpenCode
118
+
119
+ ```yaml
120
+ opencode:
121
+ integration: true
122
+ ```
123
+
124
+ OpenCode saves sessions as JSON or SQLite (depending on version). Ei reads them, extracts context per-agent (each agent like Sisyphus gets its own persona), and keeps everything current as sessions accumulate.
125
+
126
+ OpenCode can also *read* Ei's knowledge back out via the [CLI tool](src/cli/README.md) — making it a dynamic, perpetual RAG. That's why it always has context from your other projects.
127
+
128
+ #### Claude Code
129
+
130
+ ```yaml
131
+ claudeCode:
132
+ integration: true
133
+ ```
134
+
135
+ Reads from `~/.claude/projects/` (JSONL session files). All sessions map to a single "Claude Code" persona. Tool calls, thinking blocks, and internal plumbing are stripped — only the conversational content is imported.
136
+
137
+ #### Cursor
138
+
139
+ ```yaml
140
+ cursor:
141
+ integration: true
142
+ ```
143
+
144
+ Reads from Cursor's SQLite databases:
145
+ - **macOS**: `~/Library/Application Support/Cursor/User/`
146
+ - **Windows**: `%APPDATA%\Cursor\User\`
147
+ - **Linux**: `~/.config/Cursor/User/`
148
+
149
+ All sessions map to a single "Cursor" persona.
150
+
151
+ ---
152
+
153
+ Sessions are processed oldest-first, one per queue cycle, so Ei won't overwhelm your LLM provider on first run. See [TUI Readme](tui/README.md)
118
154
 
119
155
  ## Built-in Tool Integrations
120
156
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.3.8",
3
+ "version": "0.4.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -16,8 +16,6 @@ import {
16
16
  import { filterMessagesForContext } from "./context-utils.js";
17
17
  import { filterHumanDataByVisibility } from "./prompt-context-builder.js";
18
18
 
19
- const DEFAULT_CONTEXT_WINDOW_HOURS = 8;
20
-
21
19
  // =============================================================================
22
20
  // MODEL HELPERS
23
21
  // =============================================================================
@@ -189,9 +187,9 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
189
187
  const model = getModelForPersona(sm, personaId);
190
188
  console.log(`[HeartbeatCheck ${persona.display_name}] Queueing heartbeat check (model: ${model})`);
191
189
  const human = sm.getHuman();
192
- const history = sm.messages_get(personaId);
193
- const contextWindowHours = persona.context_window_hours ?? DEFAULT_CONTEXT_WINDOW_HOURS;
194
- const contextHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowHours);
190
+ const history = sm.messages_get(personaId);
191
+ const contextWindowHours = persona.context_window_hours ?? human.settings?.default_context_window_hours ?? 8;
192
+ const contextHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowHours);
195
193
 
196
194
  if (personaId === "ei") {
197
195
  await queueEiHeartbeat(sm, human, contextHistory, isTUI);
@@ -24,8 +24,6 @@ import {
24
24
  import { buildChatMessageContent } from "../prompts/message-utils.js";
25
25
  import { filterMessagesForContext } from "./context-utils.js";
26
26
 
27
- const DEFAULT_CONTEXT_WINDOW_HOURS = 8;
28
-
29
27
  // =============================================================================
30
28
  // MESSAGE QUERIES
31
29
  // =============================================================================
@@ -270,15 +268,16 @@ export function checkAndQueueHumanExtraction(
270
268
  // =============================================================================
271
269
 
272
270
  export function fetchMessagesForLLM(
273
- sm: StateManager,
274
- personaId: string
275
- ): import("./types.js").ChatMessage[] {
276
- const persona = sm.persona_getById(personaId);
277
- if (!persona) return [];
278
-
279
- const history = sm.messages_get(personaId);
280
- const contextWindowHours = persona.context_window_hours ?? DEFAULT_CONTEXT_WINDOW_HOURS;
281
- const filteredHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowHours);
271
+ sm: StateManager,
272
+ personaId: string
273
+ ): import("./types.js").ChatMessage[] {
274
+ const persona = sm.persona_getById(personaId);
275
+ if (!persona) return [];
276
+
277
+ const human = sm.getHuman();
278
+ const history = sm.messages_get(personaId);
279
+ const contextWindowHours = persona.context_window_hours ?? human.settings?.default_context_window_hours ?? 8;
280
+ const filteredHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowHours);
282
281
 
283
282
  return filteredHistory.reduce<import("./types.js").ChatMessage[]>((acc, m) => {
284
283
  const content = buildChatMessageContent(m);
@@ -1,4 +1,4 @@
1
- import { LLMRequestType, LLMPriority, LLMNextStep, MESSAGE_MIN_COUNT, MESSAGE_MAX_AGE_DAYS, type CeremonyConfig, type PersonaTopic, type Topic, type Message, type DataItemBase } from "../types.js";
1
+ import { LLMRequestType, LLMPriority, LLMNextStep, type CeremonyConfig, type PersonaTopic, type Topic, type Message, type DataItemBase } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
  import { applyDecayToValue } from "../utils/index.js";
4
4
  import {
@@ -309,14 +309,17 @@ export function prunePersonaMessages(personaId: string, state: StateManager): vo
309
309
  // Sort first — injected messages (session update, archive scan) may be out of order.
310
310
  state.messages_sort(personaId);
311
311
  const messages = state.messages_get(personaId);
312
- if (messages.length <= MESSAGE_MIN_COUNT) return;
312
+ const human = state.getHuman();
313
+ const minCount = human.settings?.message_min_count ?? 200;
314
+ const maxAgeDays = human.settings?.message_max_age_days ?? 14;
315
+ if (messages.length <= minCount) return;
313
316
 
314
- const cutoffMs = Date.now() - (MESSAGE_MAX_AGE_DAYS * 24 * 60 * 60 * 1000);
317
+ const cutoffMs = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
315
318
 
316
319
  // Messages are sorted by timestamp (oldest first from messages_sort)
317
320
  const toRemove: string[] = [];
318
321
  for (const m of messages) {
319
- if (messages.length - toRemove.length <= MESSAGE_MIN_COUNT) break;
322
+ if (messages.length - toRemove.length <= minCount) break;
320
323
 
321
324
  const msgMs = new Date(m.timestamp).getTime();
322
325
  if (msgMs >= cutoffMs) break; // Sorted by time, no more old ones
@@ -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,7 +105,6 @@ import {
105
105
  } from "./queue-manager.js";
106
106
 
107
107
  const DEFAULT_LOOP_INTERVAL_MS = 100;
108
- const DEFAULT_CONTEXT_WINDOW_HOURS = 8;
109
108
  const DEFAULT_OPENCODE_POLLING_MS = 1800000;
110
109
  const DEFAULT_CLAUDE_CODE_POLLING_MS = 1800000;
111
110
  const DEFAULT_CURSOR_POLLING_MS = 1800000;
@@ -679,6 +678,26 @@ export class Processor {
679
678
  modified = true;
680
679
  }
681
680
 
681
+ if (human.settings.default_heartbeat_ms == null) {
682
+ human.settings.default_heartbeat_ms = 1800000;
683
+ modified = true;
684
+ }
685
+
686
+ if (human.settings.default_context_window_hours == null) {
687
+ human.settings.default_context_window_hours = 8;
688
+ modified = true;
689
+ }
690
+
691
+ if (human.settings.message_min_count == null) {
692
+ human.settings.message_min_count = 200;
693
+ modified = true;
694
+ }
695
+
696
+ if (human.settings.message_max_age_days == null) {
697
+ human.settings.message_max_age_days = 14;
698
+ modified = true;
699
+ }
700
+
682
701
  if (modified) {
683
702
  this.stateManager.setHuman(human);
684
703
  console.log(`[Processor] Seeded missing settings`);
@@ -881,7 +900,6 @@ const toolNextSteps = new Set([
881
900
 
882
901
  private async checkScheduledTasks(): Promise<void> {
883
902
  const now = Date.now();
884
- const DEFAULT_HEARTBEAT_DELAY_MS = 1800000;
885
903
 
886
904
  const human = this.stateManager.getHuman();
887
905
 
@@ -926,7 +944,8 @@ const toolNextSteps = new Set([
926
944
  for (const persona of this.stateManager.persona_getAll()) {
927
945
  if (persona.is_paused || persona.is_archived) continue;
928
946
 
929
- const heartbeatDelay = persona.heartbeat_delay_ms ?? DEFAULT_HEARTBEAT_DELAY_MS;
947
+ const defaultHeartbeatMs = this.stateManager.getHuman().settings?.default_heartbeat_ms ?? 1800000;
948
+ const heartbeatDelay = persona.heartbeat_delay_ms ?? defaultHeartbeatMs;
930
949
  const lastActivity = persona.last_activity
931
950
  ? new Date(persona.last_activity).getTime()
932
951
  : 0;
@@ -939,9 +958,11 @@ const toolNextSteps = new Set([
939
958
  const timeSinceHeartbeat = now - lastHeartbeat;
940
959
 
941
960
  if (timeSinceHeartbeat >= heartbeatDelay) {
942
- const history = this.stateManager.messages_get(persona.id);
943
- const contextWindowHours =
944
- persona.context_window_hours ?? DEFAULT_CONTEXT_WINDOW_HOURS;
961
+ const history = this.stateManager.messages_get(persona.id);
962
+ const contextWindowHours =
963
+ persona.context_window_hours
964
+ ?? this.stateManager.getHuman().settings?.default_context_window_hours
965
+ ?? 8;
945
966
  const contextHistory = filterMessagesForContext(
946
967
  history,
947
968
  persona.context_boundary,
@@ -1557,6 +1578,10 @@ const toolNextSteps = new Set([
1557
1578
  return clearQueue(this.stateManager, this.queueProcessor);
1558
1579
  }
1559
1580
 
1581
+ queueUserDedup(itemType: "topic" | "person", entityIds: string[]): void {
1582
+ queueUserDedupRequest(this.stateManager, itemType, entityIds);
1583
+ }
1584
+
1560
1585
  async submitOneShot(guid: string, systemPrompt: string, userPrompt: string): Promise<void> {
1561
1586
  return submitOneShot(
1562
1587
  this.stateManager,
@@ -80,6 +80,10 @@ export interface HumanSettings {
80
80
  skip_quote_delete_confirm?: boolean;
81
81
  name_display?: string;
82
82
  time_mode?: "24h" | "12h" | "local" | "utc";
83
+ default_heartbeat_ms?: number;
84
+ default_context_window_hours?: number;
85
+ message_min_count?: number;
86
+ message_max_age_days?: number;
83
87
  accounts?: ProviderAccount[];
84
88
  sync?: SyncCredentials;
85
89
  opencode?: OpenCodeSettings;
@@ -147,10 +151,6 @@ export interface PersonaCreationInput {
147
151
  // Steps - "57:3"."inputs"."steps"
148
152
  // Cfg - "57:3"."inputs"."cfg"
149
153
  export const COMFY_PROMPT_TEMPLATE = {"9":{"inputs":{"filename_prefix":"z-image-turbo","images":["57:8",0]},"class_type":"SaveImage","_meta":{"title":"Save Image"}},"57:30":{"inputs":{"clip_name":"qwen_3_4b.safetensors","type":"lumina2","device":"default"},"class_type":"CLIPLoader","_meta":{"title":"Load CLIP"}},"57:29":{"inputs":{"vae_name":"ae.safetensors"},"class_type":"VAELoader","_meta":{"title":"Load VAE"}},"57:33":{"inputs":{"conditioning":["57:27",0]},"class_type":"ConditioningZeroOut","_meta":{"title":"ConditioningZeroOut"}},"57:8":{"inputs":{"samples":["57:3",0],"vae":["57:29",0]},"class_type":"VAEDecode","_meta":{"title":"VAE Decode"}},"57:28":{"inputs":{"unet_name":"z_image_turbo_bf16.safetensors","weight_dtype":"default"},"class_type":"UNETLoader","_meta":{"title":"Load Diffusion Model"}},"57:27":{"inputs":{"text":"This is a test prompt","clip":["57:30",0]},"class_type":"CLIPTextEncode","_meta":{"title":"CLIP Text Encode (Prompt)"}},"57:13":{"inputs":{"width":768,"height":768,"batch_size":1},"class_type":"EmptySD3LatentImage","_meta":{"title":"EmptySD3LatentImage"}},"57:11":{"inputs":{"shift":3,"model":["57:28",0]},"class_type":"ModelSamplingAuraFlow","_meta":{"title":"ModelSamplingAuraFlow"}},"57:3":{"inputs":{"seed":407776369182481,"steps":8,"cfg":1,"sampler_name":"res_multistep","scheduler":"simple","denoise":1,"model":["57:11",0],"positive":["57:27",0],"negative":["57:33",0],"latent_image":["57:13",0]},"class_type":"KSampler","_meta":{"title":"KSampler"}}};
150
- // Message pruning thresholds (shared by ceremony and import)
151
- export const MESSAGE_MIN_COUNT = 200;
152
- export const MESSAGE_MAX_AGE_DAYS = 14;
153
-
154
154
  // DLQ rolloff thresholds
155
155
  export const DLQ_MAX_COUNT = 50;
156
156
  export const DLQ_MAX_AGE_DAYS = 14;
@@ -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
@@ -2,7 +2,21 @@
2
2
 
3
3
  Ei TUI is built with OpenTUI and SolidJS.
4
4
 
5
- OpenCode integration: import via `/settings` (`opencode.integration: true`) · export via [CLI](../src/cli/README.md)
5
+ Coding tool integrations (OpenCode, Claude Code, Cursor): enable via `/settings` · export data via [CLI](../src/cli/README.md)
6
+
7
+ ## Coding Tool Integrations
8
+
9
+ Enable any or all three in `/settings`. They work independently and feed into the same knowledge base.
10
+
11
+ | Tool | Settings key | Session data location |
12
+ |------|-------------|----------------------|
13
+ | OpenCode | `opencode.integration: true` | OpenCode's local SQLite / JSON session store |
14
+ | Claude Code | `claudeCode.integration: true` | `~/.claude/projects/` (JSONL files) |
15
+ | Cursor | `cursor.integration: true` | `~/Library/Application Support/Cursor/User/` (macOS)<br>`%APPDATA%\Cursor\User\` (Windows)<br>`~/.config/Cursor/User/` (Linux) |
16
+
17
+ Sessions are processed oldest-first, one per queue cycle. On first run Ei works through your backlog gradually — it won't flood your LLM provider.
18
+
19
+ OpenCode also supports reading Ei's extracted knowledge back out via the [CLI tool](../src/cli/README.md), giving it persistent memory across sessions.
6
20
 
7
21
  # Installation
8
22
 
@@ -73,6 +87,7 @@ All commands start with `/`. Append `!` to any command as a shorthand for `--for
73
87
  |---------|---------|-------------|
74
88
  | `/me` | | Edit all your data (facts, traits, topics, people) in `$EDITOR` |
75
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` |
76
91
  | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
77
92
  | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
78
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 (
@@ -504,11 +504,16 @@ interface EditableSettingsData {
504
504
  rewrite_model?: string | null;
505
505
  time_mode?: "24h" | "12h" | "local" | "utc" | null;
506
506
  name_display?: string | null;
507
+ default_heartbeat_ms?: number | null;
508
+ default_context_window_hours?: number | null;
509
+ message_min_count?: number | null;
510
+ message_max_age_days?: number | null;
507
511
  ceremony?: {
508
512
  time: string;
509
513
  decay_rate?: number | null;
510
514
  explore_threshold?: number | null;
511
515
  dedup_threshold?: number | null;
516
+ event_window_hours?: number | null;
512
517
  };
513
518
  opencode?: {
514
519
  integration?: boolean | null;
@@ -547,11 +552,16 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
547
552
  rewrite_model: settings?.rewrite_model ?? null,
548
553
  time_mode: settings?.time_mode ?? null,
549
554
  name_display: settings?.name_display ?? null,
555
+ default_heartbeat_ms: settings?.default_heartbeat_ms ?? 1800000,
556
+ default_context_window_hours: settings?.default_context_window_hours ?? 8,
557
+ message_min_count: settings?.message_min_count ?? 200,
558
+ message_max_age_days: settings?.message_max_age_days ?? 14,
550
559
  ceremony: {
551
560
  time: settings?.ceremony?.time ?? "09:00",
552
561
  decay_rate: settings?.ceremony?.decay_rate ?? null,
553
562
  explore_threshold: settings?.ceremony?.explore_threshold ?? null,
554
563
  dedup_threshold: settings?.ceremony?.dedup_threshold ?? null,
564
+ event_window_hours: settings?.ceremony?.event_window_hours ?? null,
555
565
  },
556
566
  opencode: {
557
567
  integration: settings?.opencode?.integration ?? false,
@@ -603,6 +613,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
603
613
  decay_rate: nullToUndefined(data.ceremony.decay_rate),
604
614
  explore_threshold: nullToUndefined(data.ceremony.explore_threshold),
605
615
  dedup_threshold: nullToUndefined(data.ceremony.dedup_threshold),
616
+ event_window_hours: nullToUndefined(data.ceremony.event_window_hours),
606
617
  last_ceremony: original?.ceremony?.last_ceremony,
607
618
  };
608
619
  }
@@ -669,6 +680,10 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
669
680
  rewrite_model: nullToUndefined(data.rewrite_model),
670
681
  time_mode: nullToUndefined(data.time_mode),
671
682
  name_display: nullToUndefined(data.name_display),
683
+ default_heartbeat_ms: nullToUndefined(data.default_heartbeat_ms),
684
+ default_context_window_hours: nullToUndefined(data.default_context_window_hours),
685
+ message_min_count: nullToUndefined(data.message_min_count),
686
+ message_max_age_days: nullToUndefined(data.message_max_age_days),
672
687
  ceremony,
673
688
  opencode,
674
689
  claudeCode,