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 +42 -6
- package/package.json +1 -1
- package/src/core/heartbeat-manager.ts +3 -5
- package/src/core/message-manager.ts +10 -11
- package/src/core/orchestrators/ceremony.ts +7 -4
- 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 +32 -7
- package/src/core/types/entities.ts +4 -4
- 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 +16 -1
- 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/tui/src/util/yaml-serializers.ts +15 -0
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Ei
|
|
2
2
|
|
|
3
|
-
A local-first AI companion system with persistent personas and
|
|
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
|
|
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
|
-
###
|
|
111
|
+
### Coding Tool Integrations
|
|
112
112
|
|
|
113
|
-
Ei
|
|
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
|
-
|
|
115
|
+
All three integrations are enabled via `/settings` in the TUI.
|
|
116
116
|
|
|
117
|
-
|
|
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
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
): import("./types.js").ChatMessage[] {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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,
|
|
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
|
-
|
|
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() - (
|
|
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 <=
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
943
|
-
|
|
944
|
-
|
|
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;
|
|
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
|
|
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();
|
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 (
|
|
@@ -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,
|