ei-tui 0.1.9 → 0.1.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.1.9",
3
+ "version": "0.1.12",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli.ts CHANGED
@@ -126,6 +126,63 @@ async function installOpenCodeTool(): Promise<void> {
126
126
  console.log(` Restart OpenCode to activate.`);
127
127
  }
128
128
 
129
+ async function installClaudeCodeMcp(): Promise<void> {
130
+ const home = process.env.HOME || "~";
131
+ const claudeJsonPath = join(home, ".claude.json");
132
+
133
+ // Prefer shelling out to `claude mcp add` — lets Claude Code manage its own config
134
+ // and avoids race conditions with a live state file.
135
+ try {
136
+ const which = Bun.spawnSync(["which", "claude"], { stdout: "pipe", stderr: "pipe" });
137
+ if (which.exitCode === 0) {
138
+ const result = Bun.spawnSync(
139
+ ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "ei", "--", "ei"],
140
+ { stdout: "pipe", stderr: "pipe" }
141
+ );
142
+ if (result.exitCode === 0) {
143
+ console.log(`✓ Registered Ei as Claude Code MCP server (user scope)`);
144
+ console.log(` Restart Claude Code to activate.`);
145
+ return;
146
+ }
147
+ console.warn(` claude mcp add failed (exit ${result.exitCode}), falling back to direct write`);
148
+ }
149
+ } catch {
150
+ // claude binary not found — fall through to direct write
151
+ }
152
+
153
+ // Fallback: direct atomic write to ~/.claude.json
154
+ let config: Record<string, unknown> = {};
155
+ try {
156
+ const text = await Bun.file(claudeJsonPath).text();
157
+ config = JSON.parse(text) as Record<string, unknown>;
158
+ } catch {
159
+ // File doesn't exist or isn't valid JSON — start fresh
160
+ }
161
+
162
+ // Resolve the ei binary: if running as compiled binary, argv[1] is our path;
163
+ // if running as 'bun src/cli.ts', fall back to 'ei' (assumed on PATH after npm install -g)
164
+ const isBunScript = process.argv[1]?.endsWith("/cli.ts") || process.argv[1]?.endsWith("/cli.js");
165
+ const command = isBunScript ? "ei" : (process.argv[1] ?? "ei");
166
+
167
+ const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
168
+ mcpServers["ei"] = {
169
+ type: "stdio",
170
+ command,
171
+ args: [],
172
+ env: {},
173
+ };
174
+ config.mcpServers = mcpServers;
175
+
176
+ // Atomic write: write to temp file then rename to avoid partial writes
177
+ const tmpPath = `${claudeJsonPath}.ei-install.tmp`;
178
+ await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
179
+ const { rename } = await import(/* @vite-ignore */ "fs/promises");
180
+ await rename(tmpPath, claudeJsonPath);
181
+
182
+ console.log(`✓ Installed Ei MCP server to ${claudeJsonPath}`);
183
+ console.log(` Restart Claude Code to activate.`);
184
+ }
185
+
129
186
  async function main(): Promise<void> {
130
187
  const args = process.argv.slice(2);
131
188
 
@@ -148,6 +205,7 @@ async function main(): Promise<void> {
148
205
 
149
206
  if (args[0] === "--install") {
150
207
  await installOpenCodeTool();
208
+ await installClaudeCodeMcp();
151
209
  process.exit(0);
152
210
  }
153
211
 
@@ -276,33 +276,88 @@ function handlePersonaGeneration(response: LLMResponse, state: StateManager): vo
276
276
 
277
277
  const now = new Date().toISOString();
278
278
 
279
- const traits: Trait[] = (result?.traits || []).map(t => ({
280
- id: crypto.randomUUID(),
281
- name: t.name,
282
- description: t.description,
283
- sentiment: t.sentiment,
284
- strength: t.strength,
285
- last_updated: now,
286
- }));
279
+ // Merge LLM traits into user-provided traits by name.
280
+ // User-provided fields win; LLM fills in what the user left blank.
281
+ const userTraitsByName = new Map(
282
+ (existingPartial.traits ?? []).filter(t => t.name?.trim()).map(t => [t.name!.toLowerCase().trim(), t])
283
+ );
284
+
285
+ const mergedLlmTraits: Trait[] = (result?.traits || []).map(t => {
286
+ const userTrait = userTraitsByName.get(t.name?.toLowerCase().trim() ?? '');
287
+ return {
288
+ id: (userTrait as Trait | undefined)?.id ?? crypto.randomUUID(),
289
+ name: t.name,
290
+ description: userTrait?.description?.trim() || t.description,
291
+ sentiment: userTrait?.sentiment ?? t.sentiment ?? 0,
292
+ strength: userTrait?.strength ?? t.strength,
293
+ last_updated: now,
294
+ };
295
+ });
287
296
 
288
- const topics: PersonaTopic[] = (result?.topics || []).map(t => ({
289
- id: crypto.randomUUID(),
290
- name: t.name,
291
- perspective: t.perspective || "",
292
- approach: t.approach || "",
293
- personal_stake: t.personal_stake || "",
294
- sentiment: t.sentiment,
295
- exposure_current: t.exposure_current,
296
- exposure_desired: t.exposure_desired,
297
- last_updated: now,
298
- }));
297
+ // Keep user-provided traits the LLM didn't return
298
+ const llmTraitNames = new Set(mergedLlmTraits.map(t => t.name?.toLowerCase().trim()));
299
+ const preservedUserTraits: Trait[] = (existingPartial.traits ?? [])
300
+ .filter(t => t.name?.trim() && !llmTraitNames.has(t.name.toLowerCase().trim()))
301
+ .map(t => ({
302
+ id: (t as Trait).id ?? crypto.randomUUID(),
303
+ name: t.name!,
304
+ description: t.description || '',
305
+ sentiment: t.sentiment ?? 0,
306
+ strength: t.strength,
307
+ last_updated: now,
308
+ }));
309
+
310
+ const mergedTraits: Trait[] = mergedLlmTraits.length > 0
311
+ ? [...mergedLlmTraits, ...preservedUserTraits]
312
+ : (existingPartial.traits as Trait[] | undefined) ?? [];
313
+
314
+ // Merge LLM topics into user-provided topics by name.
315
+ // User-provided fields win; LLM fills in what the user left blank.
316
+ const userTopicsByName = new Map(
317
+ (existingPartial.topics ?? []).filter(t => t.name?.trim()).map(t => [t.name!.toLowerCase().trim(), t])
318
+ );
319
+
320
+ const llmTopics: PersonaTopic[] = (result?.topics || []).map(t => {
321
+ const userTopic = userTopicsByName.get(t.name?.toLowerCase().trim() ?? '');
322
+ return {
323
+ id: (userTopic as PersonaTopic | undefined)?.id ?? crypto.randomUUID(),
324
+ name: t.name,
325
+ perspective: userTopic?.perspective?.trim() || t.perspective || '',
326
+ approach: userTopic?.approach?.trim() || t.approach || '',
327
+ personal_stake: userTopic?.personal_stake?.trim() || t.personal_stake || '',
328
+ sentiment: userTopic?.sentiment ?? t.sentiment ?? 0,
329
+ exposure_current: userTopic?.exposure_current ?? t.exposure_current ?? 0.5,
330
+ exposure_desired: userTopic?.exposure_desired ?? t.exposure_desired ?? 0.5,
331
+ last_updated: now,
332
+ };
333
+ });
334
+
335
+ // Keep user-provided topics the LLM didn't return (not in its output list)
336
+ const llmTopicNames = new Set(llmTopics.map(t => t.name?.toLowerCase().trim()));
337
+ const preservedUserTopics: PersonaTopic[] = (existingPartial.topics ?? [])
338
+ .filter(t => t.name?.trim() && !llmTopicNames.has(t.name.toLowerCase().trim()))
339
+ .map(t => ({
340
+ id: (t as PersonaTopic).id ?? crypto.randomUUID(),
341
+ name: t.name!,
342
+ perspective: t.perspective || '',
343
+ approach: t.approach || '',
344
+ personal_stake: t.personal_stake || '',
345
+ sentiment: t.sentiment ?? 0,
346
+ exposure_current: t.exposure_current ?? 0.5,
347
+ exposure_desired: t.exposure_desired ?? 0.5,
348
+ last_updated: now,
349
+ }));
350
+
351
+ const topics: PersonaTopic[] = llmTopics.length > 0
352
+ ? [...llmTopics, ...preservedUserTopics]
353
+ : (existingPartial.topics as PersonaTopic[] | undefined) ?? [];
299
354
 
300
355
  const updatedPartial: PartialPersona = {
301
356
  ...existingPartial,
302
357
  short_description: result?.short_description ?? existingPartial.short_description,
303
358
  long_description: existingPartial.long_description ?? result?.long_description,
304
- traits: traits.length > 0 ? traits : existingPartial.traits,
305
- topics: topics.length > 0 ? topics : existingPartial.topics,
359
+ traits: mergedTraits.length > 0 ? mergedTraits : existingPartial.traits,
360
+ topics,
306
361
  };
307
362
 
308
363
  orchestratePersonaGeneration(updatedPartial, state);
@@ -259,7 +259,18 @@ export function parseJSONResponse(content: string): unknown {
259
259
 
260
260
  export function cleanResponseContent(content: string): string {
261
261
  return content
262
- .replace(/<think>[\s\S]*?<\/think>/gi, "")
263
- .replace(/<thinking>[\s\S]*?<\/thinking>/gi, "")
262
+ // Complete paired blocks (space-tolerant, case-insensitive)
263
+ .replace(/<\s*think\s*>[\s\S]*?<\s*\/\s*think\s*>/gi, "")
264
+ .replace(/<\s*thinking\s*>[\s\S]*?<\s*\/\s*thinking\s*>/gi, "")
265
+ // Seed-OSS (ByteDance) namespaced thinking tags — always paired
266
+ .replace(/<seed:think>[\s\S]*?<\/seed:think>/gi, "")
267
+ // Seed-OSS budget reflection tokens (may appear outside stripped think block)
268
+ .replace(/<seed:cot_budget_reflect>[\s\S]*?<\/seed:cot_budget_reflect>/gi, "")
269
+ // Orphaned closing tag with content before it (MiniMax / streaming accumulation)
270
+ .replace(/^[\s\S]*?<\s*\/\s*think(?:ing)?\s*>/i, "")
271
+ // Remaining orphaned closing tags
272
+ .replace(/<\s*\/\s*think(?:ing)?\s*>/gi, "")
273
+ // Remaining orphaned opening tags
274
+ .replace(/<\s*think(?:ing)?\s*>/gi, "")
264
275
  .trim();
265
276
  }
@@ -87,6 +87,7 @@ function stripHumanEmbeddings(human: HumanEntity): HumanEntity {
87
87
  const DEFAULT_LOOP_INTERVAL_MS = 100;
88
88
  const DEFAULT_CONTEXT_WINDOW_HOURS = 8;
89
89
  const DEFAULT_OPENCODE_POLLING_MS = 1800000;
90
+ const DEFAULT_CLAUDE_CODE_POLLING_MS = 1800000;
90
91
 
91
92
  let processorInstanceCount = 0;
92
93
 
@@ -127,6 +128,8 @@ export class Processor {
127
128
  private lastOpenCodeSync = 0;
128
129
  private lastDLQTrim = 0;
129
130
  private openCodeImportInProgress = false;
131
+ private lastClaudeCodeSync = 0;
132
+ private claudeCodeImportInProgress = false;
130
133
  private pendingConflict: StateConflictData | null = null;
131
134
 
132
135
  constructor(ei: Ei_Interface) {
@@ -269,6 +272,10 @@ export class Processor {
269
272
  console.log(`[Processor ${this.instanceId}] Aborting OpenCode import in progress`);
270
273
  this.openCodeImportInProgress = false;
271
274
  }
275
+ if (this.claudeCodeImportInProgress) {
276
+ console.log(`[Processor ${this.instanceId}] Aborting Claude Code import in progress`);
277
+ this.claudeCodeImportInProgress = false;
278
+ }
272
279
 
273
280
  await this.stateManager.flush();
274
281
 
@@ -383,6 +390,10 @@ export class Processor {
383
390
  if (this.isTUI && human.settings?.opencode?.integration && this.stateManager.queue_length() === 0) {
384
391
  await this.checkAndSyncOpenCode(human, now);
385
392
  }
393
+
394
+ if (this.isTUI && human.settings?.claudeCode?.integration && this.stateManager.queue_length() === 0) {
395
+ await this.checkAndSyncClaudeCode(human, now);
396
+ }
386
397
 
387
398
  if (human.settings?.ceremony && shouldStartCeremony(human.settings.ceremony, this.stateManager)) {
388
399
  // Auto-backup to remote before ceremony (if configured)
@@ -479,6 +490,60 @@ export class Processor {
479
490
  });
480
491
  }
481
492
 
493
+ private async checkAndSyncClaudeCode(human: HumanEntity, now: number): Promise<void> {
494
+ if (this.claudeCodeImportInProgress) {
495
+ return;
496
+ }
497
+
498
+ const claudeCode = human.settings?.claudeCode;
499
+ const pollingInterval = claudeCode?.polling_interval_ms ?? DEFAULT_CLAUDE_CODE_POLLING_MS;
500
+ const lastSync = claudeCode?.last_sync
501
+ ? new Date(claudeCode.last_sync).getTime()
502
+ : 0;
503
+ const timeSinceSync = now - lastSync;
504
+
505
+ if (timeSinceSync < pollingInterval && this.lastClaudeCodeSync > 0) {
506
+ return;
507
+ }
508
+
509
+ this.lastClaudeCodeSync = now;
510
+ const syncTimestamp = new Date().toISOString();
511
+ this.stateManager.setHuman({
512
+ ...this.stateManager.getHuman(),
513
+ settings: {
514
+ ...this.stateManager.getHuman().settings,
515
+ claudeCode: {
516
+ ...claudeCode,
517
+ last_sync: syncTimestamp,
518
+ },
519
+ },
520
+ });
521
+
522
+ this.claudeCodeImportInProgress = true;
523
+ import("../integrations/claude-code/importer.js")
524
+ .then(({ importClaudeCodeSessions }) =>
525
+ importClaudeCodeSessions({
526
+ stateManager: this.stateManager,
527
+ interface: this.interface,
528
+ })
529
+ )
530
+ .then((result) => {
531
+ if (result.sessionsProcessed > 0) {
532
+ console.log(
533
+ `[Processor] Claude Code sync complete: ${result.sessionsProcessed} sessions, ` +
534
+ `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
535
+ `${result.extractionScansQueued} extraction scans queued`
536
+ );
537
+ }
538
+ })
539
+ .catch((err) => {
540
+ console.warn(`[Processor] Claude Code sync failed:`, err);
541
+ })
542
+ .finally(() => {
543
+ this.claudeCodeImportInProgress = false;
544
+ });
545
+ }
546
+
482
547
  private getModelForPersona(personaId?: string): string | undefined {
483
548
  const human = this.stateManager.getHuman();
484
549
  if (personaId) {
@@ -488,6 +553,11 @@ export class Processor {
488
553
  return human.settings?.default_model;
489
554
  }
490
555
 
556
+ private getOneshotModel(): string | undefined {
557
+ const human = this.stateManager.getHuman();
558
+ return human.settings?.oneshot_model || human.settings?.default_model;
559
+ }
560
+
491
561
  private fetchMessagesForLLM(personaId: string): import("./types.js").ChatMessage[] {
492
562
  const persona = this.stateManager.persona_getById(personaId);
493
563
  if (!persona) return [];
@@ -695,6 +765,10 @@ export class Processor {
695
765
  message += ` (attempt ${response.request.attempts}, retrying in ${Math.round(result.retryDelay / 1000)}s)`;
696
766
  } else if (result.dropped) {
697
767
  message += " (permanent failure \u2014 request removed)";
768
+ if (response.request.next_step === LLMNextStep.HandleOneShot) {
769
+ const guid = response.request.data.guid as string;
770
+ this.interface.onOneShotReturned?.(guid, "");
771
+ }
698
772
  }
699
773
 
700
774
  this.interface.onError?.({ code, message });
@@ -922,9 +996,9 @@ export class Processor {
922
996
  const responsesToClear = [
923
997
  LLMNextStep.HandlePersonaResponse,
924
998
  LLMNextStep.HandlePersonaTraitExtraction,
925
- LLMNextStep.HandlePersonaTopicScan,
926
- LLMNextStep.HandlePersonaTopicMatch,
927
- LLMNextStep.HandlePersonaTopicUpdate,
999
+ LLMNextStep.HandleHeartbeatCheck, // clear stale heartbeat when user is active
1000
+ LLMNextStep.HandleEiHeartbeat, // clear stale Ei heartbeat when user is active
1001
+ // Note: TopicScan/Match/Update are ceremony-only — never clear them here
928
1002
  ];
929
1003
 
930
1004
  let removedAny = false;
@@ -1535,7 +1609,7 @@ export class Processor {
1535
1609
  system: systemPrompt,
1536
1610
  user: userPrompt,
1537
1611
  next_step: LLMNextStep.HandleOneShot,
1538
- model: this.getModelForPersona(),
1612
+ model: this.getOneshotModel(),
1539
1613
  data: { guid },
1540
1614
  });
1541
1615
  }
@@ -1,5 +1,5 @@
1
1
  import { LLMRequest, LLMResponse, LLMRequestType, ProviderAccount, ChatMessage, Message } from "./types.js";
2
- import { callLLMRaw, parseJSONResponse } from "./llm-client.js";
2
+ import { callLLMRaw, parseJSONResponse, cleanResponseContent } from "./llm-client.js";
3
3
  import { hydratePromptPlaceholders } from "../prompts/message-utils.js";
4
4
 
5
5
  type QueueProcessorState = "idle" | "busy";
@@ -131,16 +131,17 @@ export class QueueProcessor {
131
131
  content: string,
132
132
  finishReason: string | null
133
133
  ): LLMResponse {
134
+ const cleanedContent = cleanResponseContent(content);
134
135
  switch (request.type) {
135
136
  case "json" as LLMRequestType:
136
137
  case "response" as LLMRequestType:
137
- return this.handleJSONResponse(request, content, finishReason);
138
+ return this.handleJSONResponse(request, cleanedContent, finishReason);
138
139
  case "raw" as LLMRequestType:
139
140
  default:
140
141
  return {
141
142
  request,
142
143
  success: true,
143
- content,
144
+ content: cleanedContent,
144
145
  finish_reason: finishReason ?? undefined,
145
146
  };
146
147
  }
package/src/core/types.ts CHANGED
@@ -194,6 +194,7 @@ export interface CeremonyConfig {
194
194
 
195
195
  export interface HumanSettings {
196
196
  default_model?: string;
197
+ oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model
197
198
  queue_paused?: boolean;
198
199
  skip_quote_delete_confirm?: boolean;
199
200
  name_display?: string;
@@ -202,6 +203,7 @@ export interface HumanSettings {
202
203
  sync?: SyncCredentials;
203
204
  opencode?: OpenCodeSettings;
204
205
  ceremony?: CeremonyConfig;
206
+ claudeCode?: import("../integrations/claude-code/types.js").ClaudeCodeSettings;
205
207
  }
206
208
 
207
209
  export interface HumanEntity {
@@ -248,7 +250,7 @@ export interface PersonaCreationInput {
248
250
  long_description?: string;
249
251
  short_description?: string;
250
252
  traits?: Partial<Trait>[];
251
- topics?: Partial<Topic>[];
253
+ topics?: Partial<PersonaTopic>[];
252
254
  model?: string;
253
255
  group_primary?: string;
254
256
  groups_visible?: string[];