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.
- package/README.md +4 -0
- package/package.json +2 -1
- package/skills/coding-harness-reflect/SKILL.md +230 -0
- package/skills/ei-curate/SKILL.md +174 -0
- package/skills/ei-curate/references/cli.md +160 -0
- package/skills/ei-curate/references/provenance.md +88 -0
- package/skills/ei-curate/references/recipes.md +144 -0
- package/skills/ei-curate/references/talking-to-the-user.md +71 -0
- package/skills/ei-persona/SKILL.md +238 -0
- package/skills/ei-persona/references/cli.md +265 -0
- package/skills/ei-persona/references/recipes.md +247 -0
- package/skills/ei-persona/references/talking-to-the-user.md +107 -0
- package/src/cli/README.md +72 -2
- package/src/cli/commands/personas.ts +46 -1
- package/src/cli/corrections-endpoints.ts +297 -0
- package/src/cli/corrections-writer.ts +138 -0
- package/src/cli/install.ts +252 -157
- package/src/cli/mcp.ts +80 -1
- package/src/cli/persona-corrections.ts +442 -0
- package/src/cli/retrieval.ts +46 -2
- package/src/cli.ts +148 -1
- package/src/core/corrections.ts +233 -0
- package/src/core/handlers/human-extraction.ts +8 -2
- package/src/core/handlers/human-matching.ts +2 -2
- package/src/core/llm-client.ts +7 -1
- package/src/core/orchestrators/human-extraction.ts +1 -0
- package/src/core/persona-tools.ts +92 -0
- package/src/core/personas/opencode-agent.ts +1 -3
- package/src/core/processor.ts +113 -1
- package/src/core/state/human.ts +10 -0
- package/src/core/state/personas.ts +8 -0
- package/src/core/state-manager.ts +11 -0
- package/src/core/types/entities.ts +1 -0
- package/src/core/utils/identifier-utils.ts +3 -2
- package/src/integrations/pi/importer.ts +142 -50
- package/src/integrations/pi/reader.ts +1 -0
- package/src/integrations/pi/types.ts +4 -0
- package/src/storage/file-lock.ts +120 -0
- package/tui/README.md +2 -0
- package/tui/src/commands/provider.tsx +1 -1
- package/tui/src/components/WelcomeOverlay.tsx +3 -3
- package/tui/src/context/ei.tsx +14 -0
- package/tui/src/index.tsx +13 -1
- package/tui/src/storage/file.ts +15 -83
- package/tui/src/util/instance-lock.ts +3 -2
- package/tui/src/util/provider-detection.ts +4 -2
- package/tui/src/util/yaml-persona.ts +7 -38
package/src/cli/install.ts
CHANGED
|
@@ -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
|
-
|
|
86
|
+
const failures: string[] = [];
|
|
5
87
|
|
|
6
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
"
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
} else if (input.transcript_path) {
|
|
298
|
-
|
|
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
|
-
|
|
304
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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:
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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:
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
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 =
|
|
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
|
-
|
|
679
|
-
const
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
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
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
if (
|
|
766
|
-
|
|
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
|
-
|
|
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"),
|