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