ei-tui 0.1.10 → 0.1.13

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.10",
3
+ "version": "0.1.13",
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,7 +128,11 @@ 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;
134
+ private storage: Storage | null = null;
135
+ private importAbortController = new AbortController();
131
136
 
132
137
  constructor(ei: Ei_Interface) {
133
138
  this.interface = ei;
@@ -147,6 +152,7 @@ export class Processor {
147
152
 
148
153
  async start(storage: Storage): Promise<void> {
149
154
  console.log(`[Processor ${this.instanceId}] start() called`);
155
+ this.storage = storage;
150
156
  await this.stateManager.initialize(storage);
151
157
  if (this.stopped) {
152
158
  console.log(`[Processor ${this.instanceId}] stopped during init, not starting loop`);
@@ -255,6 +261,7 @@ export class Processor {
255
261
  }
256
262
 
257
263
  this.running = false;
264
+ this.importAbortController.abort();
258
265
  this.queueProcessor.abort();
259
266
  await this.stateManager.flush();
260
267
  console.log(`[Processor ${this.instanceId}] stopped`);
@@ -265,10 +272,15 @@ export class Processor {
265
272
  this.interface.onSaveAndExitStart?.();
266
273
 
267
274
  this.queueProcessor.abort();
275
+ this.importAbortController.abort();
268
276
  if (this.openCodeImportInProgress) {
269
277
  console.log(`[Processor ${this.instanceId}] Aborting OpenCode import in progress`);
270
278
  this.openCodeImportInProgress = false;
271
279
  }
280
+ if (this.claudeCodeImportInProgress) {
281
+ console.log(`[Processor ${this.instanceId}] Aborting Claude Code import in progress`);
282
+ this.claudeCodeImportInProgress = false;
283
+ }
272
284
 
273
285
  await this.stateManager.flush();
274
286
 
@@ -325,6 +337,7 @@ export class Processor {
325
337
  }
326
338
 
327
339
  this.pendingConflict = null;
340
+ this.importAbortController = new AbortController();
328
341
  this.running = true;
329
342
  this.runLoop();
330
343
  this.interface.onStateImported?.();
@@ -383,6 +396,13 @@ export class Processor {
383
396
  if (this.isTUI && human.settings?.opencode?.integration && this.stateManager.queue_length() === 0) {
384
397
  await this.checkAndSyncOpenCode(human, now);
385
398
  }
399
+
400
+ if (this.isTUI && human.settings?.backup?.enabled) {
401
+ await this.checkAndRunRollingBackup(human, now);
402
+ }
403
+ if (this.isTUI && human.settings?.claudeCode?.integration && this.stateManager.queue_length() === 0) {
404
+ await this.checkAndSyncClaudeCode(human, now);
405
+ }
386
406
 
387
407
  if (human.settings?.ceremony && shouldStartCeremony(human.settings.ceremony, this.stateManager)) {
388
408
  // Auto-backup to remote before ceremony (if configured)
@@ -425,6 +445,33 @@ export class Processor {
425
445
  }
426
446
  }
427
447
 
448
+ private async checkAndRunRollingBackup(human: HumanEntity, now: number): Promise<void> {
449
+ if (!this.storage) return;
450
+ const cfg = human.settings!.backup!;
451
+ const intervalMs = cfg.interval_ms ?? 3_600_000; // default: 1 hour
452
+ const maxBackups = cfg.max_backups ?? 24;
453
+ const lastBackup = cfg.last_backup ? new Date(cfg.last_backup).getTime() : 0;
454
+
455
+ if (now - lastBackup < intervalMs) return;
456
+
457
+ // Update timestamp BEFORE async work to prevent duplicate triggers
458
+ this.stateManager.setHuman({
459
+ ...this.stateManager.getHuman(),
460
+ settings: {
461
+ ...this.stateManager.getHuman().settings,
462
+ backup: { ...cfg, last_backup: new Date(now).toISOString() },
463
+ },
464
+ });
465
+
466
+ const state = this.stateManager.getStorageState();
467
+ try {
468
+ await this.storage.saveRollingBackup(state, maxBackups);
469
+ console.log(`[Processor] Rolling backup saved (max=${maxBackups})`);
470
+ } catch (err) {
471
+ console.warn(`[Processor] Rolling backup failed:`, err);
472
+ }
473
+ }
474
+
428
475
  private async checkAndSyncOpenCode(human: HumanEntity, now: number): Promise<void> {
429
476
  if (this.openCodeImportInProgress) {
430
477
  return;
@@ -460,6 +507,7 @@ export class Processor {
460
507
  importOpenCodeSessions({
461
508
  stateManager: this.stateManager,
462
509
  interface: this.interface,
510
+ signal: this.importAbortController.signal,
463
511
  })
464
512
  )
465
513
  .then((result) => {
@@ -479,6 +527,61 @@ export class Processor {
479
527
  });
480
528
  }
481
529
 
530
+ private async checkAndSyncClaudeCode(human: HumanEntity, now: number): Promise<void> {
531
+ if (this.claudeCodeImportInProgress) {
532
+ return;
533
+ }
534
+
535
+ const claudeCode = human.settings?.claudeCode;
536
+ const pollingInterval = claudeCode?.polling_interval_ms ?? DEFAULT_CLAUDE_CODE_POLLING_MS;
537
+ const lastSync = claudeCode?.last_sync
538
+ ? new Date(claudeCode.last_sync).getTime()
539
+ : 0;
540
+ const timeSinceSync = now - lastSync;
541
+
542
+ if (timeSinceSync < pollingInterval && this.lastClaudeCodeSync > 0) {
543
+ return;
544
+ }
545
+
546
+ this.lastClaudeCodeSync = now;
547
+ const syncTimestamp = new Date().toISOString();
548
+ this.stateManager.setHuman({
549
+ ...this.stateManager.getHuman(),
550
+ settings: {
551
+ ...this.stateManager.getHuman().settings,
552
+ claudeCode: {
553
+ ...claudeCode,
554
+ last_sync: syncTimestamp,
555
+ },
556
+ },
557
+ });
558
+
559
+ this.claudeCodeImportInProgress = true;
560
+ import("../integrations/claude-code/importer.js")
561
+ .then(({ importClaudeCodeSessions }) =>
562
+ importClaudeCodeSessions({
563
+ stateManager: this.stateManager,
564
+ interface: this.interface,
565
+ signal: this.importAbortController.signal,
566
+ })
567
+ )
568
+ .then((result) => {
569
+ if (result.sessionsProcessed > 0) {
570
+ console.log(
571
+ `[Processor] Claude Code sync complete: ${result.sessionsProcessed} sessions, ` +
572
+ `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
573
+ `${result.extractionScansQueued} extraction scans queued`
574
+ );
575
+ }
576
+ })
577
+ .catch((err) => {
578
+ console.warn(`[Processor] Claude Code sync failed:`, err);
579
+ })
580
+ .finally(() => {
581
+ this.claudeCodeImportInProgress = false;
582
+ });
583
+ }
584
+
482
585
  private getModelForPersona(personaId?: string): string | undefined {
483
586
  const human = this.stateManager.getHuman();
484
587
  if (personaId) {
@@ -488,6 +591,11 @@ export class Processor {
488
591
  return human.settings?.default_model;
489
592
  }
490
593
 
594
+ private getOneshotModel(): string | undefined {
595
+ const human = this.stateManager.getHuman();
596
+ return human.settings?.oneshot_model || human.settings?.default_model;
597
+ }
598
+
491
599
  private fetchMessagesForLLM(personaId: string): import("./types.js").ChatMessage[] {
492
600
  const persona = this.stateManager.persona_getById(personaId);
493
601
  if (!persona) return [];
@@ -695,6 +803,10 @@ export class Processor {
695
803
  message += ` (attempt ${response.request.attempts}, retrying in ${Math.round(result.retryDelay / 1000)}s)`;
696
804
  } else if (result.dropped) {
697
805
  message += " (permanent failure \u2014 request removed)";
806
+ if (response.request.next_step === LLMNextStep.HandleOneShot) {
807
+ const guid = response.request.data.guid as string;
808
+ this.interface.onOneShotReturned?.(guid, "");
809
+ }
698
810
  }
699
811
 
700
812
  this.interface.onError?.({ code, message });
@@ -922,9 +1034,9 @@ export class Processor {
922
1034
  const responsesToClear = [
923
1035
  LLMNextStep.HandlePersonaResponse,
924
1036
  LLMNextStep.HandlePersonaTraitExtraction,
925
- LLMNextStep.HandlePersonaTopicScan,
926
- LLMNextStep.HandlePersonaTopicMatch,
927
- LLMNextStep.HandlePersonaTopicUpdate,
1037
+ LLMNextStep.HandleHeartbeatCheck, // clear stale heartbeat when user is active
1038
+ LLMNextStep.HandleEiHeartbeat, // clear stale Ei heartbeat when user is active
1039
+ // Note: TopicScan/Match/Update are ceremony-only — never clear them here
928
1040
  ];
929
1041
 
930
1042
  let removedAny = false;
@@ -1535,7 +1647,7 @@ export class Processor {
1535
1647
  system: systemPrompt,
1536
1648
  user: userPrompt,
1537
1649
  next_step: LLMNextStep.HandleOneShot,
1538
- model: this.getModelForPersona(),
1650
+ model: this.getOneshotModel(),
1539
1651
  data: { guid },
1540
1652
  });
1541
1653
  }
@@ -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
@@ -192,8 +192,16 @@ export interface CeremonyConfig {
192
192
  dedup_threshold?: number; // Cosine similarity threshold for dedup candidates. Default: 0.85
193
193
  }
194
194
 
195
+ export interface BackupConfig {
196
+ enabled?: boolean; // Default: false (opt-in)
197
+ max_backups?: number; // Default: 24
198
+ interval_ms?: number; // Default: 3600000 (1 hour)
199
+ last_backup?: string; // ISO timestamp of last backup run
200
+ }
201
+
195
202
  export interface HumanSettings {
196
203
  default_model?: string;
204
+ oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model
197
205
  queue_paused?: boolean;
198
206
  skip_quote_delete_confirm?: boolean;
199
207
  name_display?: string;
@@ -202,6 +210,8 @@ export interface HumanSettings {
202
210
  sync?: SyncCredentials;
203
211
  opencode?: OpenCodeSettings;
204
212
  ceremony?: CeremonyConfig;
213
+ backup?: BackupConfig;
214
+ claudeCode?: import("../integrations/claude-code/types.js").ClaudeCodeSettings;
205
215
  }
206
216
 
207
217
  export interface HumanEntity {
@@ -248,7 +258,7 @@ export interface PersonaCreationInput {
248
258
  long_description?: string;
249
259
  short_description?: string;
250
260
  traits?: Partial<Trait>[];
251
- topics?: Partial<Topic>[];
261
+ topics?: Partial<PersonaTopic>[];
252
262
  model?: string;
253
263
  group_primary?: string;
254
264
  groups_visible?: string[];