ei-tui 1.6.2 → 1.6.4

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/cli/README.md +2 -0
  3. package/src/cli/install.ts +708 -0
  4. package/src/cli/retrieval.ts +22 -0
  5. package/src/cli/session-context.ts +98 -0
  6. package/src/cli.ts +2 -669
  7. package/src/core/bootstrap-tools.ts +486 -0
  8. package/src/core/handlers/document-segmentation.ts +1 -2
  9. package/src/core/handlers/heartbeat.ts +3 -2
  10. package/src/core/handlers/persona-response.ts +5 -4
  11. package/src/core/handlers/rooms.ts +6 -5
  12. package/src/core/integration-sync-manager.ts +482 -0
  13. package/src/core/message-manager.ts +2 -1
  14. package/src/core/migrations.ts +297 -0
  15. package/src/core/orchestrators/ceremony.ts +2 -1
  16. package/src/core/processor.ts +17 -1151
  17. package/src/core/room-manager.ts +17 -4
  18. package/src/core/state-manager.ts +2 -1
  19. package/src/core/types/entities.ts +1 -0
  20. package/src/core/utils/message-id.ts +15 -0
  21. package/src/integrations/claude-code/importer.ts +9 -30
  22. package/src/integrations/claude-code/types.ts +1 -1
  23. package/src/integrations/codex/importer.ts +6 -27
  24. package/src/integrations/codex/types.ts +1 -1
  25. package/src/integrations/constants.ts +3 -0
  26. package/src/integrations/cursor/importer.ts +9 -26
  27. package/src/integrations/cursor/types.ts +1 -1
  28. package/src/integrations/pi/importer.ts +235 -0
  29. package/src/integrations/pi/index.ts +3 -0
  30. package/src/integrations/pi/reader.ts +247 -0
  31. package/src/integrations/pi/types.ts +151 -0
  32. package/src/integrations/shared/message-converter.ts +41 -0
  33. package/src/integrations/slack/importer.ts +1 -1
  34. package/tui/README.md +1 -0
  35. package/tui/src/components/PromptInput.tsx +5 -1
  36. package/tui/src/util/yaml-settings.ts +28 -0
package/src/cli.ts CHANGED
@@ -12,10 +12,11 @@
12
12
  */
13
13
 
14
14
  import { parseArgs } from "util";
15
- import { join } from "path";
16
15
  import { retrieveBalanced, lookupById, resolveExternalMessage, loadLatestState } from "./cli/retrieval";
17
16
  import type { StorageState } from "./core/types";
18
17
  import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./cli/persona-filter.js";
18
+ import { installMcpClients } from "./cli/install.js";
19
+ import { getRecentSessionMessages } from "./cli/session-context.js";
19
20
  import pkg from "../package.json" assert { type: "json" };
20
21
 
21
22
  const rawArgs = process.argv.slice(2);
@@ -87,674 +88,6 @@ Examples:
87
88
  `);
88
89
  }
89
90
 
90
-
91
- async function installMcpClients(): Promise<void> {
92
- await installClaudeCode();
93
-
94
- const home = process.env.HOME || "~";
95
-
96
- if (await commandExists("codex")) {
97
- await installCodex();
98
- } else {
99
- console.log(`ℹ️ Codex CLI not detected — skipping Codex MCP install.`);
100
- }
101
-
102
- const cursorDataDirs = [
103
- join(home, "Library", "Application Support", "Cursor"),
104
- join(home, ".config", "Cursor"),
105
- join(home, "AppData", "Roaming", "Cursor"),
106
- ];
107
- const hasCursor = (await Promise.all(cursorDataDirs.map((p) => Bun.file(join(p, "User")).exists()))).some(Boolean);
108
- if (hasCursor) {
109
- await installCursor();
110
- } else {
111
- console.log(`ℹ️ Cursor not detected — skipping Cursor install.`);
112
- }
113
-
114
- const opencodeDir = join(home, ".config", "opencode");
115
- const hasOpenCode = await Bun.file(join(opencodeDir, "opencode.jsonc")).exists() ||
116
- await Bun.file(join(opencodeDir, "opencode.json")).exists() ||
117
- await Bun.file(join(opencodeDir, "opencode.db")).exists();
118
-
119
- if (hasOpenCode) {
120
- await installOpenCodePlugin();
121
- } else {
122
- console.log(`ℹ️ OpenCode not detected — skipping OpenCode plugin install.`);
123
- }
124
- }
125
-
126
- async function commandExists(command: string): Promise<boolean> {
127
- try {
128
- const proc = Bun.spawn([command, "--version"], {
129
- stdout: "ignore",
130
- stderr: "ignore",
131
- });
132
- await proc.exited;
133
- return proc.exitCode === 0;
134
- } catch {
135
- return false;
136
- }
137
- }
138
-
139
- function hookEntryHasCommand(entry: unknown, command: string): boolean {
140
- if (typeof entry !== "object" || entry === null || !("hooks" in entry)) return false;
141
- const hooks = (entry as { hooks?: unknown }).hooks;
142
- if (!Array.isArray(hooks)) return false;
143
-
144
- return hooks.some((hook) => {
145
- if (typeof hook !== "object" || hook === null) return false;
146
- const candidate = hook as { type?: unknown; command?: unknown };
147
- return candidate.type === "command" && candidate.command === command;
148
- });
149
- }
150
-
151
- async function installCodex(): Promise<void> {
152
- const dataPath = process.env.EI_DATA_PATH ?? join(process.env.HOME || "~", ".local", "share", "ei");
153
- const proc = Bun.spawn(
154
- ["codex", "mcp", "add", "ei", "--env", `EI_DATA_PATH=${dataPath}`, "--", "bunx", "ei-tui", "mcp"],
155
- {
156
- stdout: "pipe",
157
- stderr: "pipe",
158
- }
159
- );
160
-
161
- const [stdout, stderr, exitCode] = await Promise.all([
162
- new Response(proc.stdout).text(),
163
- new Response(proc.stderr).text(),
164
- proc.exited,
165
- ]);
166
-
167
- if (exitCode !== 0) {
168
- console.warn(`⚠️ Codex MCP install failed.`);
169
- const detail = (stderr || stdout).trim();
170
- if (detail) console.warn(` ${detail}`);
171
- } else {
172
- console.log(`✓ Installed Ei MCP server to Codex config (~/.codex/config.toml)`);
173
- console.log(` Restart Codex to activate MCP.`);
174
- }
175
-
176
- await installCodexHooks();
177
- }
178
-
179
- async function installCodexHooks(): Promise<void> {
180
- const home = process.env.HOME || "~";
181
- const hooksDir = join(home, ".codex", "hooks");
182
- const scriptPath = join(hooksDir, "ei-inject.ts");
183
- const hooksJsonPath = join(home, ".codex", "hooks.json");
184
-
185
- await Bun.$`mkdir -p ${hooksDir}`;
186
-
187
- try {
188
- await Bun.$`test -w ${hooksDir}`.quiet();
189
- } catch {
190
- console.warn(`⚠️ Cannot write to ${hooksDir} (permission denied).`);
191
- console.warn(` Fix with: sudo chown ${process.env.USER ?? "$(whoami)"} ${hooksDir}`);
192
- console.warn(` Then re-run: ei --install`);
193
- return;
194
- }
195
-
196
- const scriptContent = `#!/usr/bin/env bun
197
- import { $ } from "bun";
198
-
199
- const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
200
- const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
201
- const searchArgs = ["-n", "8"];
202
-
203
- const sessionArgs = [];
204
- if (input.transcript_path) {
205
- sessionArgs.push("--transcript", input.transcript_path);
206
- }
207
- if (input.session_id) {
208
- sessionArgs.push("--session", input.session_id, "--hook-source", "codex");
209
- }
210
-
211
- const args = raw ? [...searchArgs, ...sessionArgs, raw] : ["--recent", ...searchArgs];
212
-
213
- async function runEi(commandArgs) {
214
- const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
215
- if (direct.trim()) return direct;
216
- return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
217
- }
218
-
219
- const output = await runEi(args);
220
- if (output.trim()) {
221
- const heading = [
222
- "## Ei Memory Context",
223
- "*(The user cannot see this block. It is injected automatically before their message.)*",
224
- "*(If you reference anything from it, briefly explain where it came from — e.g. \\"Ei shows you've been working on X\\" — so the user isn't confused by knowledge that appeared from nowhere.)*",
225
- "",
226
- "Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.",
227
- "The following memories MAY be relevant to your current task — use \`ei_search\` or \`ei_lookup\` for targeted queries.",
228
- ].join("\\n");
229
-
230
- process.stdout.write(JSON.stringify({
231
- hookSpecificOutput: {
232
- hookEventName: "UserPromptSubmit",
233
- additionalContext: \`\\n\${heading}\\n\${output.trim()}\\n\`,
234
- },
235
- }));
236
- }
237
- `;
238
-
239
- await Bun.write(scriptPath, scriptContent);
240
- await Bun.$`chmod +x ${scriptPath}`;
241
-
242
- type CodexUserPromptHook = {
243
- hooks: Array<{ type: string; command: string; statusMessage?: string; timeout?: number }>;
244
- };
245
-
246
- interface CodexHooksConfig {
247
- hooks: {
248
- UserPromptSubmit?: CodexUserPromptHook[];
249
- [key: string]: unknown;
250
- };
251
- }
252
-
253
- let hooksConfig: CodexHooksConfig = { hooks: {} };
254
- try {
255
- const text = await Bun.file(hooksJsonPath).text();
256
- hooksConfig = JSON.parse(text) as CodexHooksConfig;
257
- if (!hooksConfig.hooks || typeof hooksConfig.hooks !== "object") {
258
- hooksConfig.hooks = {};
259
- }
260
- } catch {
261
- // File doesn't exist or isn't valid JSON — start fresh
262
- }
263
-
264
- const userPromptSubmit = (hooksConfig.hooks.UserPromptSubmit ?? []) as CodexUserPromptHook[];
265
- const hookEntry = {
266
- hooks: [{
267
- type: "command",
268
- command: scriptPath,
269
- statusMessage: "Loading Ei memory context",
270
- timeout: 30,
271
- }],
272
- };
273
- const alreadyInstalled = userPromptSubmit.some((entry) => hookEntryHasCommand(entry, scriptPath));
274
- if (!alreadyInstalled) {
275
- userPromptSubmit.push(hookEntry);
276
- }
277
-
278
- hooksConfig.hooks.UserPromptSubmit = userPromptSubmit;
279
-
280
- const tmpPath = `${hooksJsonPath}.ei-install.tmp`;
281
- await Bun.write(tmpPath, JSON.stringify(hooksConfig, null, 2) + "\n");
282
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
283
- await rename(tmpPath, hooksJsonPath);
284
-
285
- console.log(`✓ Installed Ei Codex context hook to ~/.codex/hooks/ei-inject.ts`);
286
- console.log(` Use /hooks in Codex to review/trust the hook if prompted.`);
287
- }
288
-
289
- async function installClaudeCode(): Promise<void> {
290
- const home = process.env.HOME || "~";
291
- const claudeJsonPath = join(home, ".claude.json");
292
-
293
- // Claude Code supports ${VAR} substitution in env values, resolved from its
294
- // own environment at spawn time — so the value stays fresh if EI_DATA_PATH changes.
295
- const mcpEntry: Record<string, unknown> = {
296
- type: "stdio",
297
- command: "bunx",
298
- args: ["ei-tui", "mcp"],
299
- env: { EI_DATA_PATH: "${EI_DATA_PATH}" },
300
- };
301
-
302
- // Direct atomic write — we need full control over the config structure to
303
- // write the env field. `claude mcp add` doesn't support env vars.
304
- let config: Record<string, unknown> = {};
305
- try {
306
- const text = await Bun.file(claudeJsonPath).text();
307
- config = JSON.parse(text) as Record<string, unknown>;
308
- } catch {
309
- // File doesn't exist or isn't valid JSON — start fresh
310
- }
311
-
312
- const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
313
- mcpServers["ei"] = mcpEntry;
314
- config.mcpServers = mcpServers;
315
-
316
- // Atomic write: write to temp file then rename to avoid partial writes
317
- const tmpPath = `${claudeJsonPath}.ei-install.tmp`;
318
- await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
319
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
320
- await rename(tmpPath, claudeJsonPath);
321
-
322
- console.log(`✓ Installed Ei MCP server to ${claudeJsonPath}`);
323
- console.log(` Restart Claude Code to activate.`);
324
-
325
- await installClaudeCodeHooks();
326
- }
327
-
328
- async function installClaudeCodeHooks(): Promise<void> {
329
- const home = process.env.HOME || "~";
330
- const hooksDir = join(home, ".claude", "hooks");
331
- const scriptPath = join(hooksDir, "ei-inject.ts");
332
- const settingsPath = join(home, ".claude", "settings.json");
333
-
334
- await Bun.$`mkdir -p ${hooksDir}`;
335
-
336
- try {
337
- await Bun.$`test -w ${hooksDir}`.quiet();
338
- } catch {
339
- console.warn(`⚠️ Cannot write to ${hooksDir} (permission denied).`);
340
- console.warn(` Fix with: sudo chown ${process.env.USER ?? "$(whoami)"} ${hooksDir}`);
341
- console.warn(` Then re-run: ei --install`);
342
- return;
343
- }
344
-
345
- const scriptContent = `#!/usr/bin/env bun
346
- import { $ } from "bun";
347
-
348
- const heading = \`
349
- ## Ei Memory Context
350
- *(The user cannot see this block. It is injected automatically before their message.)*
351
- *(If you reference anything from it, briefly explain where it came from — e.g. "Ei shows you've been working on X" — so the user isn't confused by knowledge that appeared from nowhere.)*
352
-
353
- Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.
354
- The following topics MAY be relevant to your current task — use \\\`ei_search\\\` or \\\`ei_lookup\\\` for targeted queries.
355
- \`;
356
-
357
- const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
358
- const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
359
- const typeArgs = ["topics", "-n", "5"];
360
-
361
- const sessionArgs = [];
362
- if (input.session_id && input.hook_source) {
363
- sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
364
- } else if (input.transcript_path) {
365
- sessionArgs.push("--transcript", input.transcript_path);
366
- }
367
-
368
- const args = raw ? [...typeArgs, ...sessionArgs, raw] : ["--recent", ...typeArgs];
369
-
370
- const output = await $\`bunx ei-tui@latest \${args}\`.quiet().text().catch(() => "");
371
- if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
372
- `;
373
-
374
- await Bun.write(scriptPath, scriptContent);
375
- await Bun.$`chmod +x ${scriptPath}`;
376
-
377
- let settings: Record<string, unknown> = {};
378
- try {
379
- const text = await Bun.file(settingsPath).text();
380
- settings = JSON.parse(text) as Record<string, unknown>;
381
- } catch {
382
- // File doesn't exist or isn't valid JSON — start fresh
383
- }
384
-
385
- const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
386
- const userPromptSubmit = (hooks.UserPromptSubmit ?? []) as unknown[];
387
-
388
- const hookEntry = { hooks: [{ type: "command", command: "~/.claude/hooks/ei-inject.ts" }] };
389
- const alreadyInstalled = userPromptSubmit.some((entry) => hookEntryHasCommand(entry, "~/.claude/hooks/ei-inject.ts"));
390
- if (!alreadyInstalled) {
391
- userPromptSubmit.push(hookEntry);
392
- }
393
-
394
- hooks.UserPromptSubmit = userPromptSubmit;
395
- settings.hooks = hooks;
396
-
397
- // Atomic write: write to temp file then rename to avoid partial writes
398
- const tmpPath = `${settingsPath}.ei-install.tmp`;
399
- await Bun.write(tmpPath, JSON.stringify(settings, null, 2) + "\n");
400
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
401
- await rename(tmpPath, settingsPath);
402
-
403
- console.log(`✓ Installed Ei context hook to ~/.claude/hooks/ei-inject.ts`);
404
- }
405
-
406
- async function installCursor(): Promise<void> {
407
- const home = process.env.HOME || "~";
408
- const cursorJsonPath = join(home, ".cursor", "mcp.json");
409
-
410
- // Cursor does not support ${VAR} substitution in mcp.json — literal values only.
411
- const mcpEntry: Record<string, unknown> = {
412
- type: "stdio",
413
- command: "bunx",
414
- args: ["ei-tui", "mcp"],
415
- env: { EI_DATA_PATH: process.env.EI_DATA_PATH ?? "" },
416
- };
417
-
418
- let config: Record<string, unknown> = {};
419
- try {
420
- const text = await Bun.file(cursorJsonPath).text();
421
- config = JSON.parse(text) as Record<string, unknown>;
422
- } catch {
423
- // File doesn't exist or isn't valid JSON — start fresh
424
- }
425
-
426
- const mcpServers = (config.mcpServers ?? {}) as Record<string, unknown>;
427
- mcpServers["ei"] = mcpEntry;
428
- config.mcpServers = mcpServers;
429
-
430
- await Bun.$`mkdir -p ${join(home, ".cursor")}`;
431
- const tmpPath = `${cursorJsonPath}.ei-install.tmp`;
432
- await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
433
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
434
- await rename(tmpPath, cursorJsonPath);
435
-
436
- console.log(`✓ Installed Ei MCP server to ${cursorJsonPath}`);
437
- console.log(` Restart Cursor to activate.`);
438
-
439
- await installCursorHooks();
440
- }
441
-
442
- async function installCursorHooks(): Promise<void> {
443
- const home = process.env.HOME || "~";
444
- const hooksDir = join(home, ".cursor", "hooks");
445
- const rulesDir = join(home, ".cursor", "rules");
446
- const hookScriptPath = join(hooksDir, "ei-inject.sh");
447
- const hooksJsonPath = join(home, ".cursor", "hooks.json");
448
-
449
- await Bun.$`mkdir -p ${hooksDir}`;
450
- await Bun.$`mkdir -p ${rulesDir}`;
451
-
452
- const hookScript = `#!/bin/bash
453
- # Ei memory context injection hook for Cursor
454
- # Writes recent Ei context to ~/.cursor/rules/ei-context.mdc (alwaysApply)
455
- # so Cursor includes it automatically on the next prompt.
456
-
457
- RULES_FILE="$HOME/.cursor/rules/ei-context.mdc"
458
- CONTEXT=$(ei --recent -n 10 2>/dev/null)
459
-
460
- if [ -n "$CONTEXT" ]; then
461
- cat > "$RULES_FILE" << 'RULE'
462
- ---
463
- description: Ei persistent memory context (auto-updated before each prompt)
464
- alwaysApply: true
465
- ---
466
- RULE
467
- echo "## Ei Memory (recent context)" >> "$RULES_FILE"
468
- echo "$CONTEXT" >> "$RULES_FILE"
469
- fi
470
-
471
- # Always exit 0 — never block Cursor
472
- exit 0
473
- `;
474
-
475
- await Bun.write(hookScriptPath, hookScript);
476
- await Bun.$`chmod +x ${hookScriptPath}`;
477
-
478
- interface HooksConfig {
479
- version: number;
480
- hooks: {
481
- beforeSubmitPrompt?: Array<{ command: string }>;
482
- [key: string]: unknown;
483
- };
484
- }
485
-
486
- let hooksConfig: HooksConfig = { version: 1, hooks: {} };
487
- try {
488
- const text = await Bun.file(hooksJsonPath).text();
489
- hooksConfig = JSON.parse(text) as HooksConfig;
490
- } catch {
491
- // File doesn't exist or isn't valid JSON — start fresh
492
- }
493
-
494
- const beforeSubmit = (hooksConfig.hooks.beforeSubmitPrompt ?? []) as Array<{ command: string }>;
495
- const eiEntry = { command: "~/.cursor/hooks/ei-inject.sh" };
496
- const alreadyPresent = beforeSubmit.some((entry) => entry.command === eiEntry.command);
497
- if (!alreadyPresent) {
498
- beforeSubmit.push(eiEntry);
499
- }
500
- hooksConfig.hooks.beforeSubmitPrompt = beforeSubmit;
501
-
502
- const tmpPath = `${hooksJsonPath}.ei-install.tmp`;
503
- await Bun.write(tmpPath, JSON.stringify(hooksConfig, null, 2) + "\n");
504
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
505
- await rename(tmpPath, hooksJsonPath);
506
-
507
- console.log(`✓ Installed Ei context hook to ~/.cursor/hooks/ei-inject.sh`);
508
- }
509
-
510
- async function installOpenCodePlugin(): Promise<void> {
511
- const home = process.env.HOME || "~";
512
- const opencodeDir = join(home, ".config", "opencode");
513
- const pluginsDir = join(opencodeDir, "plugins");
514
- const pluginPath = join(pluginsDir, "ei-persona.ts");
515
-
516
- await Bun.$`mkdir -p ${pluginsDir}`;
517
-
518
- const pluginContent = `import { $ } from "bun"
519
- import { join } from "path"
520
- import { appendFileSync } from "fs"
521
-
522
- const sessionCache = new Map<string, string | null>()
523
- const sessionFetch = new Map<string, Promise<string | null>>()
524
-
525
- const logPath = join(process.env.EI_DATA_PATH ?? join(process.env.HOME ?? "~", ".local", "share", "ei"), "ei-persona-plugin.log")
526
-
527
- function log(msg: string) {
528
- try {
529
- appendFileSync(logPath, \`[\${new Date().toISOString()}] \${msg}\\n\`)
530
- } catch {}
531
- }
532
-
533
- type PersonaTrait = { name: string; description: string; strength: number }
534
- type PersonaTopic = { name: string; perspective: string; approach: string; exposure_current: number }
535
- type PersonaResult = { display_name: string; base_prompt?: string; traits?: PersonaTrait[]; topics?: PersonaTopic[] }
536
-
537
- // Pulls the agent name from the system prompt. Handles OMO's multiple formats:
538
- // You are "Sisyphus" - ... (quoted, dash)
539
- // You are "Sisyphus - Ultraworker" (quoted, dash in name)
540
- // You are Atlas - ... (unquoted, dash)
541
- // You are Hephaestus, ... (unquoted, comma)
542
- export function extractAgentName(systemPrompt: string): string | null {
543
- const clean = systemPrompt.replace(/[\\u200B-\\u200D\\uFEFF]/g, "")
544
- const quoted = clean.match(/You are "([^"]+)"/)
545
- if (quoted?.[1]) return quoted[1].trim()
546
- const unquoted = clean.match(/You are ([A-Za-z][A-Za-z0-9]*)(?:\\s*[-—,]|\\s*$)/m)
547
- if (unquoted?.[1]) return unquoted[1].trim()
548
- return null
549
- }
550
-
551
- // Queries Ei for persona candidates and validates by name containment —
552
- // tolerates OMO renaming agents without requiring a hardcoded alias map.
553
- export async function resolveEiPersona(rawName: string): Promise<PersonaResult | null> {
554
- try {
555
- const out = await $\`bunx ei-tui@latest personas -n 5 \${rawName}\`.text()
556
- const candidates = JSON.parse(out.trim()) as PersonaResult[]
557
- if (!Array.isArray(candidates) || candidates.length === 0) return null
558
- const rawLower = rawName.toLowerCase()
559
- const match = candidates.find((p) => {
560
- const nameLower = p.display_name.toLowerCase()
561
- return rawLower.includes(nameLower) || nameLower.includes(rawLower)
562
- })
563
- return match ?? null
564
- } catch {
565
- return null
566
- }
567
- }
568
-
569
- function buildEiRelationshipBlock(persona: PersonaResult): string {
570
- const strongTraits = (persona.traits ?? [])
571
- .filter((t) => t.strength >= 0.7)
572
- .sort((a, b) => b.strength - a.strength)
573
- .map((t) => \`**\${t.name}** (\${Math.round(t.strength * 100)}%): \${t.description}\`)
574
- .join("\\n")
575
- const sortedTopics = [...(persona.topics ?? [])]
576
- .sort((a, b) => b.exposure_current - a.exposure_current)
577
- .map((t) => \`**\${t.name}**: \${t.perspective} — \${t.approach}\`)
578
- .join("\\n")
579
- return [
580
- "<!-- ei-relationship-injected -->",
581
- "<ei-relationship>",
582
- "## Ei: Relationship Context",
583
- "",
584
- persona.base_prompt ?? "",
585
- "",
586
- "### Working Style",
587
- strongTraits || "(no traits above threshold)",
588
- "",
589
- "### Shared Context",
590
- sortedTopics || "(no topics)",
591
- "</ei-relationship>",
592
- ].join("\\n")
593
- }
594
-
595
- export default async function EiPersonaPlugin() {
596
- return {
597
- name: "ei-persona",
598
- "experimental.chat.system.transform": async (
599
- input: { sessionID?: string; model: { id: string; providerID: string; [key: string]: unknown } },
600
- output: { system: string[] },
601
- ): Promise<void> => {
602
- const rawName = extractAgentName(output.system[0] ?? "")
603
- if (!rawName) return
604
-
605
- const cacheKey = \`\${input.sessionID ?? "unknown"}:\${rawName}\`
606
-
607
- if (sessionCache.has(cacheKey)) {
608
- const cached = sessionCache.get(cacheKey) ?? null
609
- if (cached !== null && !output.system[0].includes("<!-- ei-relationship-injected -->"))
610
- output.system[0] = output.system[0] + "\\n\\n" + cached
611
- return
612
- }
613
-
614
- if (!sessionFetch.has(cacheKey)) {
615
- sessionFetch.set(cacheKey, (async () => {
616
- const persona = await resolveEiPersona(rawName)
617
- if (!persona) return null
618
- log(\`ei-persona: injecting \${persona.display_name}\`)
619
- return buildEiRelationshipBlock(persona)
620
- })())
621
- }
622
-
623
- const block = await sessionFetch.get(cacheKey)!
624
- sessionCache.set(cacheKey, block)
625
- if (block !== null && !output.system[0].includes("<!-- ei-relationship-injected -->"))
626
- output.system[0] = output.system[0] + "\\n\\n" + block
627
- },
628
- }
629
- }
630
- `;
631
-
632
- await Bun.write(pluginPath, pluginContent);
633
- console.log(`✓ Installed Ei persona plugin to ${pluginPath}`);
634
-
635
- const omoCandidates = [
636
- join(opencodeDir, "oh-my-opencode.json"),
637
- join(opencodeDir, "oh-my-opencode.jsonc"),
638
- join(opencodeDir, "oh-my-openagent.json"),
639
- join(opencodeDir, "oh-my-openagent.jsonc"),
640
- join(opencodeDir, "node_modules", "oh-my-opencode", "package.json"),
641
- join(opencodeDir, "node_modules", "oh-my-openagent", "package.json"),
642
- ];
643
- const hasOmo = (await Promise.all(omoCandidates.map((p) => Bun.file(p).exists()))).some(Boolean);
644
-
645
- if (!hasOmo) {
646
- console.log(`
647
- ℹ️ Oh My OpenCode not detected.
648
- The Ei persona plugin is installed, but context injection (hook) requires OMO.
649
- For full Ei integration in OpenCode, we recommend:
650
-
651
- bunx oh-my-opencode install
652
-
653
- OMO picks up the Ei UserPromptSubmit hook automatically via its Claude Code
654
- compatibility layer.
655
- `);
656
- }
657
- }
658
-
659
- async function getRecentSessionMessages(
660
- sessionId: string | undefined,
661
- hookSource: string | undefined,
662
- transcriptPath: string | undefined
663
- ): Promise<string[]> {
664
- if (transcriptPath) {
665
- try {
666
- const text = await Bun.file(transcriptPath).text();
667
-
668
- const { parseCodexRolloutMessages } = await import(
669
- /* @vite-ignore */ "./integrations/codex/reader.js"
670
- );
671
- const codexMessages = parseCodexRolloutMessages(text, sessionId ?? "transcript");
672
- if (codexMessages.length > 0) {
673
- return codexMessages.slice(-5).map((m) => `${m.role}: ${m.content}`);
674
- }
675
-
676
- const messages: Array<{ content: string }> = [];
677
-
678
- for (const line of text.split("\n")) {
679
- const trimmed = line.trim();
680
- if (!trimmed) continue;
681
- let record: Record<string, unknown>;
682
- try {
683
- record = JSON.parse(trimmed) as Record<string, unknown>;
684
- } catch {
685
- continue;
686
- }
687
-
688
- if (record.type === "user") {
689
- const msgContent = (record.message as Record<string, unknown>)?.content;
690
- if (typeof msgContent === "string" && msgContent.trim()) {
691
- messages.push({ content: msgContent.trim() });
692
- }
693
- } else if (record.type === "assistant") {
694
- const msgContent = (record.message as Record<string, unknown>)?.content;
695
- if (Array.isArray(msgContent)) {
696
- const extracted = (msgContent as Array<Record<string, unknown>>)
697
- .filter((b) => b.type === "text" && typeof b.text === "string")
698
- .map((b) => b.text as string)
699
- .join("\n\n")
700
- .trim();
701
- if (extracted) {
702
- messages.push({ content: extracted });
703
- }
704
- }
705
- }
706
- }
707
-
708
- return messages.slice(-5).map((m) => m.content);
709
- } catch {
710
- return [];
711
- }
712
- }
713
-
714
- if (!sessionId || !hookSource) return [];
715
-
716
- try {
717
- if (hookSource === "opencode-plugin") {
718
- const { createOpenCodeReader } = await import(
719
- /* @vite-ignore */ "./integrations/opencode/reader-factory.js"
720
- );
721
- const reader = await createOpenCodeReader();
722
- const messages = await reader.getMessagesForSession(sessionId);
723
- return messages.slice(-5).map((m) => `${m.role}: ${m.content}`);
724
- }
725
-
726
- if (hookSource === "cursor") {
727
- const { CursorReader } = await import(
728
- /* @vite-ignore */ "./integrations/cursor/reader.js"
729
- );
730
- const reader = new CursorReader();
731
- const sessions = await reader.getSessions();
732
- const session =
733
- sessions.find((s) => s.id === sessionId) ?? sessions[sessions.length - 1];
734
- if (session) {
735
- return session.messages.slice(-5).map((m) => `${m.type === 1 ? "user" : "assistant"}: ${m.text}`);
736
- }
737
- }
738
-
739
- if (hookSource === "codex") {
740
- const { CodexReader } = await import(
741
- /* @vite-ignore */ "./integrations/codex/reader.js"
742
- );
743
- const reader = new CodexReader();
744
- const sessions = await reader.getSessions();
745
- const session =
746
- sessions.find((s) => s.id === sessionId) ?? sessions[sessions.length - 1];
747
- if (session) {
748
- return session.messages.slice(-5).map((m) => `${m.role}: ${m.content}`);
749
- }
750
- }
751
- } catch {
752
- return [];
753
- }
754
-
755
- return [];
756
- }
757
-
758
91
  async function main(): Promise<void> {
759
92
  const args = process.argv.slice(2);
760
93