ei-tui 1.6.8 → 1.7.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.
Files changed (47) hide show
  1. package/README.md +4 -0
  2. package/package.json +2 -1
  3. package/skills/coding-harness-reflect/SKILL.md +230 -0
  4. package/skills/ei-curate/SKILL.md +174 -0
  5. package/skills/ei-curate/references/cli.md +160 -0
  6. package/skills/ei-curate/references/provenance.md +88 -0
  7. package/skills/ei-curate/references/recipes.md +144 -0
  8. package/skills/ei-curate/references/talking-to-the-user.md +71 -0
  9. package/skills/ei-persona/SKILL.md +238 -0
  10. package/skills/ei-persona/references/cli.md +265 -0
  11. package/skills/ei-persona/references/recipes.md +247 -0
  12. package/skills/ei-persona/references/talking-to-the-user.md +107 -0
  13. package/src/cli/README.md +72 -2
  14. package/src/cli/commands/personas.ts +46 -1
  15. package/src/cli/corrections-endpoints.ts +297 -0
  16. package/src/cli/corrections-writer.ts +138 -0
  17. package/src/cli/install.ts +252 -157
  18. package/src/cli/mcp.ts +80 -1
  19. package/src/cli/persona-corrections.ts +442 -0
  20. package/src/cli/retrieval.ts +46 -2
  21. package/src/cli.ts +148 -1
  22. package/src/core/corrections.ts +233 -0
  23. package/src/core/handlers/human-extraction.ts +8 -2
  24. package/src/core/handlers/human-matching.ts +2 -2
  25. package/src/core/llm-client.ts +7 -1
  26. package/src/core/orchestrators/human-extraction.ts +1 -0
  27. package/src/core/persona-tools.ts +92 -0
  28. package/src/core/personas/opencode-agent.ts +1 -3
  29. package/src/core/processor.ts +113 -1
  30. package/src/core/state/human.ts +10 -0
  31. package/src/core/state/personas.ts +8 -0
  32. package/src/core/state-manager.ts +11 -0
  33. package/src/core/types/entities.ts +1 -0
  34. package/src/core/utils/identifier-utils.ts +3 -2
  35. package/src/integrations/pi/importer.ts +142 -50
  36. package/src/integrations/pi/reader.ts +1 -0
  37. package/src/integrations/pi/types.ts +4 -0
  38. package/src/storage/file-lock.ts +120 -0
  39. package/tui/README.md +2 -0
  40. package/tui/src/commands/provider.tsx +1 -1
  41. package/tui/src/components/WelcomeOverlay.tsx +3 -3
  42. package/tui/src/context/ei.tsx +14 -0
  43. package/tui/src/index.tsx +13 -1
  44. package/tui/src/storage/file.ts +15 -83
  45. package/tui/src/util/instance-lock.ts +3 -2
  46. package/tui/src/util/provider-detection.ts +4 -2
  47. package/tui/src/util/yaml-persona.ts +7 -38
@@ -1,12 +1,96 @@
1
1
  import { join } from "path";
2
+ import { fileURLToPath } from "url";
3
+ import { homedir } from "os";
4
+ import { cp, mkdir, readdir, rename, rm, stat } from "fs/promises";
5
+
6
+ /**
7
+ * Copy every skills/<name>/ directory from Ei's own package into a
8
+ * harness's native skill-discovery directory (targetDir). Copy, not
9
+ * symlink — a symlink into an npm/bunx-installed package's cache breaks
10
+ * silently on upgrade/uninstall, and Windows symlinks need elevated
11
+ * permissions; every other install* function in this file already
12
+ * materializes content onto disk rather than referencing back to source.
13
+ * Generic over whatever exists under skills/ — adding a new Ei-shipped
14
+ * skill later requires zero changes here. Overwrites unconditionally on
15
+ * every run, same as the extension files below.
16
+ *
17
+ * `sourceDir` defaults to Ei's own packaged skills/ (resolved relative to
18
+ * this file's own location, so it works regardless of install method —
19
+ * global npm, bunx, or a from-source checkout) and exists as a parameter
20
+ * purely so tests can redirect it at a fixture directory instead.
21
+ */
22
+ export async function installSkillsTo(targetDir: string, sourceDir?: string): Promise<void> {
23
+ // `.pathname` on a file:// URL keeps a leading slash before a Windows
24
+ // drive letter (`/C:/Users/...`), which fs APIs on Windows do not accept
25
+ // as an absolute path — fileURLToPath() normalizes it correctly on every OS.
26
+ const skillsSourceDir = sourceDir ?? fileURLToPath(new URL("../../skills", import.meta.url));
27
+
28
+ // Plain fs.stat instead of shelling out to `test -d` — `test` is not a
29
+ // Bun Shell builtin (it falls back to a PATH lookup), and stock Windows
30
+ // ships no `test` binary, so the old shell-based check silently treated
31
+ // every Windows install as "source doesn't exist" and skipped skill
32
+ // installation with no warning at all.
33
+ try {
34
+ const sourceStat = await stat(skillsSourceDir);
35
+ if (!sourceStat.isDirectory()) return;
36
+ } catch {
37
+ // From-source checkout or a package build that predates this feature —
38
+ // nothing to do yet, and that's not an error.
39
+ return;
40
+ }
41
+
42
+ // fs.readdir + isDirectory() instead of `ls -d dir/*/` — skips stray files
43
+ // directly under skills/ (e.g. a top-level README.md) without depending on
44
+ // Bun Shell's undocumented `-d` flag support or POSIX glob semantics.
45
+ const entries = await readdir(skillsSourceDir, { withFileTypes: true });
46
+ const skillNames = entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
47
+ if (skillNames.length === 0) return;
48
+
49
+ await mkdir(targetDir, { recursive: true });
50
+
51
+ for (const skillName of skillNames) {
52
+ const dest = join(targetDir, skillName);
53
+ // fs.cp merges into an existing dest rather than nesting like a naive
54
+ // `cp -r` would, but it won't remove files that only exist in a stale
55
+ // prior copy — clear first so every run leaves an exact mirror of the
56
+ // current source, matching the unconditional full-overwrite behavior
57
+ // every other install* function in this file gets via Bun.write.
58
+ await rm(dest, { recursive: true, force: true });
59
+ await cp(join(skillsSourceDir, skillName), dest, { recursive: true });
60
+ }
61
+
62
+ console.log(`✓ Installed ${skillNames.length} skill(s) to ${targetDir}`);
63
+ }
64
+
65
+ function resolveHome(): string {
66
+ // Plain Windows shells (cmd.exe, PowerShell without Git Bash/WSL) don't set
67
+ // $HOME — os.homedir() reads USERPROFILE there instead. Without this
68
+ // fallback, `home` silently became the literal string "~", and every
69
+ // downstream join("~", ...) resolved relative to CWD instead of the user's
70
+ // actual profile directory.
71
+ return process.env.HOME || homedir();
72
+ }
73
+
74
+ export async function runInstallStep(label: string, step: () => Promise<void>): Promise<boolean> {
75
+ try {
76
+ await step();
77
+ return true;
78
+ } catch (e) {
79
+ console.warn(`⚠️ ${label} install step failed: ${e instanceof Error ? e.message : String(e)}`);
80
+ console.warn(` Skipping — other integrations will still be attempted.`);
81
+ return false;
82
+ }
83
+ }
2
84
 
3
85
  export async function installMcpClients(): Promise<void> {
4
- await installClaudeCode();
86
+ const failures: string[] = [];
5
87
 
6
- const home = process.env.HOME || "~";
88
+ if (!(await runInstallStep("Claude Code", installClaudeCode))) failures.push("Claude Code");
89
+
90
+ const home = resolveHome();
7
91
 
8
92
  if (await commandExists("codex")) {
9
- await installCodex();
93
+ if (!(await runInstallStep("Codex", installCodex))) failures.push("Codex");
10
94
  } else {
11
95
  console.log(`ℹ️ Codex CLI not detected — skipping Codex MCP install.`);
12
96
  }
@@ -18,7 +102,7 @@ export async function installMcpClients(): Promise<void> {
18
102
  ];
19
103
  const hasCursor = (await Promise.all(cursorDataDirs.map((p) => Bun.file(join(p, "User")).exists()))).some(Boolean);
20
104
  if (hasCursor) {
21
- await installCursor();
105
+ if (!(await runInstallStep("Cursor", installCursor))) failures.push("Cursor");
22
106
  } else {
23
107
  console.log(`ℹ️ Cursor not detected — skipping Cursor install.`);
24
108
  }
@@ -29,7 +113,7 @@ export async function installMcpClients(): Promise<void> {
29
113
  await Bun.file(join(opencodeDir, "opencode.db")).exists();
30
114
 
31
115
  if (hasOpenCode) {
32
- await installOpenCodePlugin();
116
+ if (!(await runInstallStep("OpenCode plugin", installOpenCodePlugin))) failures.push("OpenCode plugin");
33
117
  } else {
34
118
  console.log(`ℹ️ OpenCode not detected — skipping OpenCode plugin install.`);
35
119
  }
@@ -39,7 +123,7 @@ export async function installMcpClients(): Promise<void> {
39
123
  await Bun.file(join(home, ".pi", "agent", "auth.json")).exists();
40
124
 
41
125
  if (hasPi) {
42
- await installPi();
126
+ if (!(await runInstallStep("Pi extension", installPi))) failures.push("Pi extension");
43
127
  } else {
44
128
  console.log(`ℹ️ Pi not detected — skipping Pi extension install.`);
45
129
  }
@@ -51,10 +135,16 @@ export async function installMcpClients(): Promise<void> {
51
135
  await Bun.file(join(home, ".omp", "agent", "agent.db")).exists();
52
136
 
53
137
  if (hasOmp) {
54
- await installOmp();
138
+ if (!(await runInstallStep("OMP extension", installOmp))) failures.push("OMP extension");
55
139
  } else {
56
140
  console.log(`ℹ️ OMP not detected — skipping OMP extension install.`);
57
141
  }
142
+
143
+ if (failures.length > 0) {
144
+ throw new Error(
145
+ `${failures.length} integration(s) failed to install: ${failures.join(", ")}. See warnings above for details.`,
146
+ );
147
+ }
58
148
  }
59
149
 
60
150
  async function commandExists(command: string): Promise<boolean> {
@@ -83,7 +173,7 @@ function hookEntryHasCommand(entry: unknown, command: string): boolean {
83
173
  }
84
174
 
85
175
  async function installCodex(): Promise<void> {
86
- const dataPath = process.env.EI_DATA_PATH ?? join(process.env.HOME || "~", ".local", "share", "ei");
176
+ const dataPath = process.env.EI_DATA_PATH ?? join(resolveHome(), ".local", "share", "ei");
87
177
  const proc = Bun.spawn(
88
178
  ["codex", "mcp", "add", "ei", "--env", `EI_DATA_PATH=${dataPath}`, "--", "bunx", "ei-tui", "mcp"],
89
179
  {
@@ -111,7 +201,7 @@ async function installCodex(): Promise<void> {
111
201
  }
112
202
 
113
203
  async function installCodexHooks(): Promise<void> {
114
- const home = process.env.HOME || "~";
204
+ const home = resolveHome();
115
205
  const hooksDir = join(home, ".codex", "hooks");
116
206
  const scriptPath = join(hooksDir, "ei-inject.ts");
117
207
  const hooksJsonPath = join(home, ".codex", "hooks.json");
@@ -130,43 +220,45 @@ async function installCodexHooks(): Promise<void> {
130
220
  const scriptContent = `#!/usr/bin/env bun
131
221
  import { $ } from "bun";
132
222
 
133
- const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
134
- const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
135
- const searchArgs = ["-n", "8"];
136
-
137
- const sessionArgs = [];
138
- if (input.transcript_path) {
139
- sessionArgs.push("--transcript", input.transcript_path);
140
- }
141
- if (input.session_id) {
142
- sessionArgs.push("--session", input.session_id, "--hook-source", "codex");
143
- }
144
-
145
- const args = raw ? [...searchArgs, ...sessionArgs, raw] : ["--recent", ...searchArgs];
146
-
147
223
  async function runEi(commandArgs) {
148
224
  const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
149
225
  if (direct.trim()) return direct;
150
226
  return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
151
227
  }
152
228
 
153
- const output = await runEi(args);
154
- if (output.trim()) {
155
- const heading = [
156
- "## Ei Memory Context",
157
- "*(The user cannot see this block. It is injected automatically before their message.)*",
158
- "*(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.)*",
159
- "",
160
- "Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.",
161
- "The following memories MAY be relevant to your current task — use \`ei_search\` or \`ei_lookup\` for targeted queries.",
162
- ].join("\\n");
163
-
164
- process.stdout.write(JSON.stringify({
165
- hookSpecificOutput: {
166
- hookEventName: "UserPromptSubmit",
167
- additionalContext: \`\\n\${heading}\\n\${output.trim()}\\n\`,
168
- },
169
- }));
229
+ if (import.meta.main) {
230
+ const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
231
+ const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
232
+ const searchArgs = ["-n", "8"];
233
+
234
+ const sessionArgs = [];
235
+ if (input.transcript_path) {
236
+ sessionArgs.push("--transcript", input.transcript_path);
237
+ }
238
+ if (input.session_id) {
239
+ sessionArgs.push("--session", input.session_id, "--hook-source", "codex");
240
+ }
241
+
242
+ const args = raw ? [...searchArgs, ...sessionArgs, raw] : ["--recent", ...searchArgs];
243
+
244
+ const output = await runEi(args);
245
+ if (output.trim()) {
246
+ const heading = [
247
+ "## Ei Memory Context",
248
+ "*(The user cannot see this block. It is injected automatically before their message.)*",
249
+ "*(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.)*",
250
+ "",
251
+ "Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.",
252
+ "The following memories MAY be relevant to your current task — use \`ei_search\` or \`ei_lookup\` for targeted queries.",
253
+ ].join("\\n");
254
+
255
+ process.stdout.write(JSON.stringify({
256
+ hookSpecificOutput: {
257
+ hookEventName: "UserPromptSubmit",
258
+ additionalContext: \`\\n\${heading}\\n\${output.trim()}\\n\`,
259
+ },
260
+ }));
261
+ }
170
262
  }
171
263
  `;
172
264
 
@@ -213,15 +305,14 @@ if (output.trim()) {
213
305
 
214
306
  const tmpPath = `${hooksJsonPath}.ei-install.tmp`;
215
307
  await Bun.write(tmpPath, JSON.stringify(hooksConfig, null, 2) + "\n");
216
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
217
308
  await rename(tmpPath, hooksJsonPath);
218
309
 
219
310
  console.log(`✓ Installed Ei Codex context hook to ~/.codex/hooks/ei-inject.ts`);
220
311
  console.log(` Use /hooks in Codex to review/trust the hook if prompted.`);
221
312
  }
222
313
 
223
- async function installClaudeCode(): Promise<void> {
224
- const home = process.env.HOME || "~";
314
+ export async function installClaudeCode(): Promise<void> {
315
+ const home = resolveHome();
225
316
  const claudeJsonPath = join(home, ".claude.json");
226
317
 
227
318
  // Claude Code supports ${VAR} substitution in env values, resolved from its
@@ -250,17 +341,18 @@ async function installClaudeCode(): Promise<void> {
250
341
  // Atomic write: write to temp file then rename to avoid partial writes
251
342
  const tmpPath = `${claudeJsonPath}.ei-install.tmp`;
252
343
  await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
253
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
254
344
  await rename(tmpPath, claudeJsonPath);
255
345
 
256
346
  console.log(`✓ Installed Ei MCP server to ${claudeJsonPath}`);
257
347
  console.log(` Restart Claude Code to activate.`);
258
348
 
259
349
  await installClaudeCodeHooks();
350
+
351
+ await installSkillsTo(join(home, ".claude", "skills"));
260
352
  }
261
353
 
262
354
  async function installClaudeCodeHooks(): Promise<void> {
263
- const home = process.env.HOME || "~";
355
+ const home = resolveHome();
264
356
  const hooksDir = join(home, ".claude", "hooks");
265
357
  const scriptPath = join(hooksDir, "ei-inject.ts");
266
358
  const settingsPath = join(home, ".claude", "settings.json");
@@ -279,7 +371,14 @@ async function installClaudeCodeHooks(): Promise<void> {
279
371
  const scriptContent = `#!/usr/bin/env bun
280
372
  import { $ } from "bun";
281
373
 
282
- const heading = \`
374
+ async function runEi(commandArgs) {
375
+ const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
376
+ if (direct.trim()) return direct;
377
+ return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
378
+ }
379
+
380
+ if (import.meta.main) {
381
+ const heading = \`
283
382
  ## Ei Memory Context
284
383
  *(The user cannot see this block. It is injected automatically before their message.)*
285
384
  *(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.)*
@@ -288,25 +387,21 @@ Ei is a personal knowledge base built from the user's coding sessions, Slack, do
288
387
  The following items MAY be relevant to your current task — use \\\`ei_search\\\` or \\\`ei_lookup\\\` for targeted queries.
289
388
  \`;
290
389
 
291
- const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
292
- const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
390
+ const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
391
+ const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
293
392
 
294
- const sessionArgs = [];
295
- if (input.session_id && input.hook_source) {
296
- sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
297
- } else if (input.transcript_path) {
298
- sessionArgs.push("--transcript", input.transcript_path);
299
- }
393
+ const sessionArgs = [];
394
+ if (input.session_id && input.hook_source) {
395
+ sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
396
+ } else if (input.transcript_path) {
397
+ sessionArgs.push("--transcript", input.transcript_path);
398
+ }
300
399
 
301
- const args = raw ? ["-n", "5", ...sessionArgs, raw] : ["--recent", "-n", "5"];
400
+ const args = raw ? ["-n", "5", ...sessionArgs, raw] : ["--recent", "-n", "5"];
302
401
 
303
- async function runEi(commandArgs) {
304
- const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
305
- if (direct.trim()) return direct;
306
- return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
402
+ const output = await runEi(args);
403
+ if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
307
404
  }
308
- const output = await runEi(args);
309
- if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
310
405
  `;
311
406
 
312
407
  await Bun.write(scriptPath, scriptContent);
@@ -335,14 +430,13 @@ if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\
335
430
  // Atomic write: write to temp file then rename to avoid partial writes
336
431
  const tmpPath = `${settingsPath}.ei-install.tmp`;
337
432
  await Bun.write(tmpPath, JSON.stringify(settings, null, 2) + "\n");
338
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
339
433
  await rename(tmpPath, settingsPath);
340
434
 
341
435
  console.log(`✓ Installed Ei context hook to ~/.claude/hooks/ei-inject.ts`);
342
436
  }
343
437
 
344
438
  async function installCursor(): Promise<void> {
345
- const home = process.env.HOME || "~";
439
+ const home = resolveHome();
346
440
  const cursorJsonPath = join(home, ".cursor", "mcp.json");
347
441
 
348
442
  // Cursor does not support ${VAR} substitution in mcp.json — literal values only.
@@ -368,7 +462,6 @@ async function installCursor(): Promise<void> {
368
462
  await Bun.$`mkdir -p ${join(home, ".cursor")}`;
369
463
  const tmpPath = `${cursorJsonPath}.ei-install.tmp`;
370
464
  await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
371
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
372
465
  await rename(tmpPath, cursorJsonPath);
373
466
 
374
467
  console.log(`✓ Installed Ei MCP server to ${cursorJsonPath}`);
@@ -378,7 +471,7 @@ async function installCursor(): Promise<void> {
378
471
  }
379
472
 
380
473
  async function installCursorHooks(): Promise<void> {
381
- const home = process.env.HOME || "~";
474
+ const home = resolveHome();
382
475
  const hooksDir = join(home, ".cursor", "hooks");
383
476
  const rulesDir = join(home, ".cursor", "rules");
384
477
  const hookScriptPath = join(hooksDir, "ei-inject.sh");
@@ -439,14 +532,13 @@ exit 0
439
532
 
440
533
  const tmpPath = `${hooksJsonPath}.ei-install.tmp`;
441
534
  await Bun.write(tmpPath, JSON.stringify(hooksConfig, null, 2) + "\n");
442
- const { rename } = await import(/* @vite-ignore */ "fs/promises");
443
535
  await rename(tmpPath, hooksJsonPath);
444
536
 
445
537
  console.log(`✓ Installed Ei context hook to ~/.cursor/hooks/ei-inject.sh`);
446
538
  }
447
539
 
448
540
  async function installPi(): Promise<void> {
449
- const home = process.env.HOME || "~";
541
+ const home = resolveHome();
450
542
 
451
543
  const extensionContent = `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
452
544
  import { Type } from "typebox";
@@ -554,11 +646,10 @@ export default function eiIntegration(pi: ExtensionAPI) {
554
646
  console.log(`✓ Installed Ei extension to ~/.pi/agent/extensions/${extFilename}`);
555
647
  }
556
648
 
557
- async function installOmp(): Promise<void> {
558
- const home = process.env.HOME || "~";
649
+ export async function installOmp(): Promise<void> {
650
+ const home = resolveHome();
559
651
 
560
652
  const extensionContent = `import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
561
- import { Type } from "typebox";
562
653
  import { $ } from "bun";
563
654
 
564
655
  const runEi = async (cmdArgs: string[]): Promise<string> => {
@@ -567,8 +658,46 @@ const runEi = async (cmdArgs: string[]): Promise<string> => {
567
658
  return $\`bunx ei-tui@latest \${cmdArgs}\`.quiet().text().catch(() => "");
568
659
  };
569
660
 
661
+ // WHO block deduplication: Promise identity reuse — resolving is synchronous on subsequent calls.
662
+ const personaBlockFetch = new Map<string, Promise<string | null>>();
663
+
664
+ async function fetchPersonaBlock(name: string): Promise<string | null> {
665
+ try {
666
+ const block = await runEi(["personas", "--format", "prompt", "--", name]);
667
+ return block.trim() || null;
668
+ } catch {
669
+ return null;
670
+ }
671
+ }
570
672
 
571
673
  export default function eiIntegration(pi: ExtensionAPI) {
674
+ // WHO: inject <ei-relationship> block for the active primary persona.
675
+ // Prefer ctx.activePersonaName (OMP >= persona-tab-cycle PR); fall back to
676
+ // parsing "You are \\"<Name>\\"" from the HOW block in event.systemPrompt.
677
+ pi.on("before_agent_start", async (event, ctx) => {
678
+ const joined = ((event as any).systemPrompt as string[] | undefined)?.join("\\n") ?? "";
679
+ const quoted = joined.match(/You are "([^"]+)"/);
680
+ const personaName: string | null =
681
+ (ctx as any).activePersonaName ??
682
+ (quoted?.[1]?.trim() || null);
683
+ if (!personaName) return undefined;
684
+
685
+ if (!personaBlockFetch.has(personaName)) {
686
+ personaBlockFetch.set(personaName, fetchPersonaBlock(personaName));
687
+ }
688
+ const block = await personaBlockFetch.get(personaName)!;
689
+ if (!block) return undefined;
690
+
691
+ return {
692
+ message: {
693
+ customType: "ei-persona-who",
694
+ content: block,
695
+ display: false,
696
+ },
697
+ };
698
+ });
699
+
700
+ // MEMORY: inject relevant Ei context based on the current prompt.
572
701
  pi.on("before_agent_start", async (event, ctx) => {
573
702
  const entries = ctx.sessionManager.getEntries();
574
703
  const recentMsgs = entries
@@ -584,12 +713,8 @@ export default function eiIntegration(pi: ExtensionAPI) {
584
713
  .join("\\n");
585
714
 
586
715
  const prompt = event.prompt ?? "";
587
- const args = prompt
588
- ? ["-n", "5", "--", prompt]
589
- : ["--recent", "-n", "5"];
590
-
716
+ const args = prompt ? ["-n", "5", "--", prompt] : ["--recent", "-n", "5"];
591
717
  const output = await runEi(args).catch(() => "");
592
-
593
718
  if (!output.trim()) return undefined;
594
719
 
595
720
  const heading = [
@@ -610,22 +735,25 @@ export default function eiIntegration(pi: ExtensionAPI) {
610
735
  };
611
736
  });
612
737
 
738
+ // Tools use plain JSON Schema — no typebox import needed (not available in source mode).
613
739
  pi.registerTool({
614
740
  name: "ei_search",
615
741
  label: "Search Ei Memory",
616
742
  description: "Semantic search of Ei's personal knowledge base — facts, topics, people, quotes across all sources. Use when you need context about the user, their work, or anything Ei has learned.",
617
743
  promptSnippet: "Search Ei's personal memory for relevant facts, topics, people, or quotes.",
618
- parameters: Type.Object({
619
- query: Type.String({ description: "Natural language search query" }),
620
- type: Type.Optional(Type.Union([
621
- Type.Literal("facts"),
622
- Type.Literal("topics"),
623
- Type.Literal("people"),
624
- Type.Literal("quotes"),
625
- Type.Literal("personas"),
626
- ], { description: "Filter to a specific data type. Omit for balanced results across all types." })),
627
- }),
628
- async execute(_id, params, _signal, _onUpdate, _ctx) {
744
+ parameters: {
745
+ type: "object",
746
+ properties: {
747
+ query: { type: "string", description: "Natural language search query" },
748
+ type: {
749
+ type: "string",
750
+ enum: ["facts", "topics", "people", "quotes", "personas"],
751
+ description: "Filter to a specific data type. Omit for balanced results across all types.",
752
+ },
753
+ },
754
+ required: ["query"],
755
+ },
756
+ async execute(_id, params: { query: string; type?: string }, _signal, _onUpdate, _ctx) {
629
757
  const args = params.type
630
758
  ? [params.type, "-n", "5", "--", params.query]
631
759
  : ["-n", "5", "--", params.query];
@@ -641,10 +769,14 @@ export default function eiIntegration(pi: ExtensionAPI) {
641
769
  name: "ei_lookup",
642
770
  label: "Lookup Ei Entity",
643
771
  description: "Full-record lookup for a specific Ei entity (Fact, Topic, Person, Quote, or Persona) by ID. Use after ei_search to retrieve complete details for an item.",
644
- parameters: Type.Object({
645
- id: Type.String({ description: "Entity ID from ei_search results" }),
646
- }),
647
- async execute(_id, params, _signal, _onUpdate, _ctx) {
772
+ parameters: {
773
+ type: "object",
774
+ properties: {
775
+ id: { type: "string", description: "Entity ID from ei_search results" },
776
+ },
777
+ required: ["id"],
778
+ },
779
+ async execute(_id, params: { id: string }, _signal, _onUpdate, _ctx) {
648
780
  const output = await runEi(["--id", params.id]).catch(() => "");
649
781
  return {
650
782
  content: [{ type: "text" as const, text: output.trim() || "Not found" }],
@@ -661,10 +793,12 @@ export default function eiIntegration(pi: ExtensionAPI) {
661
793
  await Bun.$`mkdir -p ${extDir}`;
662
794
  await Bun.write(join(extDir, extFilename), extensionContent);
663
795
  console.log(`✓ Installed Ei extension to ~/.omp/agent/extensions/${extFilename}`);
796
+
797
+ await installSkillsTo(join(home, ".omp", "agent", "skills"));
664
798
  }
665
799
 
666
- async function installOpenCodePlugin(): Promise<void> {
667
- const home = process.env.HOME || "~";
800
+ export async function installOpenCodePlugin(): Promise<void> {
801
+ const home = resolveHome();
668
802
  const opencodeDir = join(home, ".config", "opencode");
669
803
  const pluginsDir = join(opencodeDir, "plugins");
670
804
  const pluginPath = join(pluginsDir, "ei-persona.ts");
@@ -674,11 +808,12 @@ async function installOpenCodePlugin(): Promise<void> {
674
808
  const pluginContent = `import { $ } from "bun"
675
809
  import { join } from "path"
676
810
  import { appendFileSync } from "fs"
811
+ import { homedir } from "os"
677
812
 
678
- const sessionCache = new Map<string, string | null>()
679
- const sessionFetch = new Map<string, Promise<string | null>>()
813
+ // Deduplication: the Promise itself is re-awaited on subsequent calls (synchronous once resolved).
814
+ const personaFetch = new Map<string, Promise<string | null>>()
680
815
 
681
- const logPath = join(process.env.EI_DATA_PATH ?? join(process.env.HOME ?? "~", ".local", "share", "ei"), "ei-persona-plugin.log")
816
+ const logPath = join(process.env.EI_DATA_PATH ?? join(process.env.HOME ?? homedir(), ".local", "share", "ei"), "ei-persona-plugin.log")
682
817
 
683
818
  function log(msg: string) {
684
819
  try {
@@ -686,11 +821,7 @@ function log(msg: string) {
686
821
  } catch {}
687
822
  }
688
823
 
689
- type PersonaTrait = { name: string; description: string; strength: number }
690
- type PersonaTopic = { name: string; perspective: string; approach: string; exposure_current: number }
691
- type PersonaResult = { display_name: string; base_prompt?: string; traits?: PersonaTrait[]; topics?: PersonaTopic[] }
692
-
693
- // Pulls the agent name from the system prompt. Handles OMO's multiple formats:
824
+ // Pulls the agent name from the system prompt. Handles OMO/OMP formats:
694
825
  // You are "Sisyphus" - ... (quoted, dash)
695
826
  // You are "Sisyphus - Ultraworker" (quoted, dash in name)
696
827
  // You are Atlas - ... (unquoted, dash)
@@ -704,51 +835,26 @@ export function extractAgentName(systemPrompt: string): string | null {
704
835
  return null
705
836
  }
706
837
 
707
- // Queries Ei for persona candidates and validates by name containment —
708
- // tolerates OMO renaming agents without requiring a hardcoded alias map.
709
- export async function resolveEiPersona(rawName: string): Promise<PersonaResult | null> {
838
+ const runEi = async (cmdArgs: string[]): Promise<string> => {
839
+ const direct = await $\`ei \${cmdArgs}\`.quiet().text().catch(() => "")
840
+ if (direct.trim()) return direct
841
+ return $\`bunx ei-tui@latest \${cmdArgs}\`.quiet().text().catch(() => "")
842
+ }
843
+
844
+ // Fetch the <ei-relationship> block for a named persona via the Ei CLI.
845
+ // Delegates all formatting to \`ei personas <name> --format prompt\` so
846
+ // the block format is maintained in one place.
847
+ async function fetchRelationshipBlock(rawName: string): Promise<string | null> {
710
848
  try {
711
- const direct = await $\`ei personas -n 5 \${rawName}\`.quiet().text().catch(() => "")
712
- const out = direct.trim() ? direct : await $\`bunx ei-tui@latest personas -n 5 \${rawName}\`.text()
713
- const candidates = JSON.parse(out.trim()) as PersonaResult[]
714
- if (!Array.isArray(candidates) || candidates.length === 0) return null
715
- const rawLower = rawName.toLowerCase()
716
- const match = candidates.find((p) => {
717
- const nameLower = p.display_name.toLowerCase()
718
- return rawLower.includes(nameLower) || nameLower.includes(rawLower)
719
- })
720
- return match ?? null
849
+ const block = await runEi(["personas", "--format", "prompt", "--", rawName])
850
+ if (!block.trim() || block.includes("No saved state")) return null
851
+ log(\`ei-persona: injecting block for \${rawName}\`)
852
+ return block.trim()
721
853
  } catch {
722
854
  return null
723
855
  }
724
856
  }
725
857
 
726
- function buildEiRelationshipBlock(persona: PersonaResult): string {
727
- const strongTraits = (persona.traits ?? [])
728
- .filter((t) => t.strength >= 0.7)
729
- .sort((a, b) => b.strength - a.strength)
730
- .map((t) => \`**\${t.name}** (\${Math.round(t.strength * 100)}%): \${t.description}\`)
731
- .join("\\n")
732
- const sortedTopics = [...(persona.topics ?? [])]
733
- .sort((a, b) => b.exposure_current - a.exposure_current)
734
- .map((t) => \`**\${t.name}**: \${t.perspective} — \${t.approach}\`)
735
- .join("\\n")
736
- return [
737
- "<!-- ei-relationship-injected -->",
738
- "<ei-relationship>",
739
- "## Ei: Relationship Context",
740
- "",
741
- persona.base_prompt ?? "",
742
- "",
743
- "### Working Style",
744
- strongTraits || "(no traits above threshold)",
745
- "",
746
- "### Shared Context",
747
- sortedTopics || "(no topics)",
748
- "</ei-relationship>",
749
- ].join("\\n")
750
- }
751
-
752
858
  export default async function EiPersonaPlugin() {
753
859
  return {
754
860
  name: "ei-persona",
@@ -760,26 +866,13 @@ export default async function EiPersonaPlugin() {
760
866
  const rawName = extractAgentName(output.system[0])
761
867
  if (!rawName) return
762
868
 
763
- const cacheKey = \`\${input.sessionID ?? "unknown"}:\${rawName}\`
764
-
765
- if (sessionCache.has(cacheKey)) {
766
- const cached = sessionCache.get(cacheKey) ?? null
767
- if (cached !== null && !output.system[0].includes("<!-- ei-relationship-injected -->"))
768
- output.system[0] = output.system[0] + "\\n\\n" + cached
769
- return
869
+ // Cache per persona name (not per session) — block only changes when the
870
+ // persona's Ei data changes, which is infrequent.
871
+ if (!personaFetch.has(rawName)) {
872
+ personaFetch.set(rawName, fetchRelationshipBlock(rawName))
770
873
  }
771
874
 
772
- if (!sessionFetch.has(cacheKey)) {
773
- sessionFetch.set(cacheKey, (async () => {
774
- const persona = await resolveEiPersona(rawName)
775
- if (!persona) return null
776
- log(\`ei-persona: injecting \${persona.display_name}\`)
777
- return buildEiRelationshipBlock(persona)
778
- })())
779
- }
780
-
781
- const block = await sessionFetch.get(cacheKey)!
782
- sessionCache.set(cacheKey, block)
875
+ const block = await personaFetch.get(rawName)!
783
876
  if (block !== null && !output.system[0].includes("<!-- ei-relationship-injected -->"))
784
877
  output.system[0] = output.system[0] + "\\n\\n" + block
785
878
  },
@@ -790,6 +883,8 @@ export default async function EiPersonaPlugin() {
790
883
  await Bun.write(pluginPath, pluginContent);
791
884
  console.log(`✓ Installed Ei persona plugin to ${pluginPath}`);
792
885
 
886
+ await installSkillsTo(join(opencodeDir, "skills"));
887
+
793
888
  const omoCandidates = [
794
889
  join(opencodeDir, "oh-my-opencode.json"),
795
890
  join(opencodeDir, "oh-my-opencode.jsonc"),