ei-tui 1.4.1 → 1.6.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 +2 -2
- package/package.json +1 -1
- package/src/cli/README.md +25 -88
- package/src/cli/mcp.ts +3 -123
- package/src/cli/retrieval.ts +3 -34
- package/src/cli.ts +425 -27
- package/src/core/orchestrators/ceremony.ts +5 -3
- package/src/core/processor.ts +50 -13
- package/src/core/tools/builtin/find-memory.ts +1 -1
- package/src/core/types/data-items.ts +1 -1
- package/src/core/types/entities.ts +2 -0
- package/src/integrations/slack/importer.ts +36 -15
- package/src/integrations/slack/reader.ts +23 -10
- package/src/integrations/slack/types.ts +27 -9
- package/src/prompts/message-utils.ts +1 -1
- package/tui/README.md +1 -1
- package/tui/src/commands/slack-auth.ts +13 -7
- package/tui/src/util/yaml-settings.ts +38 -10
package/src/cli.ts
CHANGED
|
@@ -63,13 +63,16 @@ Types:
|
|
|
63
63
|
persona / personas Personas in this Ei instance
|
|
64
64
|
|
|
65
65
|
Options:
|
|
66
|
-
--number, -n
|
|
67
|
-
--recent, -r
|
|
68
|
-
--persona, -p
|
|
69
|
-
--source, -s
|
|
70
|
-
--id
|
|
71
|
-
--install
|
|
72
|
-
--
|
|
66
|
+
--number, -n Maximum number of results (default: 10)
|
|
67
|
+
--recent, -r Sort by last_mentioned date (most recent first)
|
|
68
|
+
--persona, -p Filter to entities a specific persona has learned about
|
|
69
|
+
--source, -s Filter to entities from a specific source (prefix match, e.g. "cursor", "opencode:my-machine", "opencode:my-machine:ses_abc123")
|
|
70
|
+
--id Look up entity by ID (accepts value or stdin)
|
|
71
|
+
--install Register Ei with Claude Code, Cursor, and OpenCode (MCP + context hooks)
|
|
72
|
+
--session <id> Session ID to enrich the query with recent context (use with --hook-source)
|
|
73
|
+
--hook-source <src> Source of the hook: "opencode-plugin" (OpenCode SQLite) or "cursor"
|
|
74
|
+
--transcript <path> Path to a Claude Code JSONL transcript file for context enrichment
|
|
75
|
+
--help, -h Show this help message
|
|
73
76
|
|
|
74
77
|
Examples:
|
|
75
78
|
ei "debugging" # Search everything
|
|
@@ -87,7 +90,31 @@ Examples:
|
|
|
87
90
|
|
|
88
91
|
async function installMcpClients(): Promise<void> {
|
|
89
92
|
await installClaudeCode();
|
|
90
|
-
|
|
93
|
+
|
|
94
|
+
const home = process.env.HOME || "~";
|
|
95
|
+
|
|
96
|
+
const cursorDataDirs = [
|
|
97
|
+
join(home, "Library", "Application Support", "Cursor"),
|
|
98
|
+
join(home, ".config", "Cursor"),
|
|
99
|
+
join(home, "AppData", "Roaming", "Cursor"),
|
|
100
|
+
];
|
|
101
|
+
const hasCursor = (await Promise.all(cursorDataDirs.map((p) => Bun.file(join(p, "User")).exists()))).some(Boolean);
|
|
102
|
+
if (hasCursor) {
|
|
103
|
+
await installCursor();
|
|
104
|
+
} else {
|
|
105
|
+
console.log(`ℹ️ Cursor not detected — skipping Cursor install.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const opencodeDir = join(home, ".config", "opencode");
|
|
109
|
+
const hasOpenCode = await Bun.file(join(opencodeDir, "opencode.jsonc")).exists() ||
|
|
110
|
+
await Bun.file(join(opencodeDir, "opencode.json")).exists() ||
|
|
111
|
+
await Bun.file(join(opencodeDir, "opencode.db")).exists();
|
|
112
|
+
|
|
113
|
+
if (hasOpenCode) {
|
|
114
|
+
await installOpenCodePlugin();
|
|
115
|
+
} else {
|
|
116
|
+
console.log(`ℹ️ OpenCode not detected — skipping OpenCode plugin install.`);
|
|
117
|
+
}
|
|
91
118
|
}
|
|
92
119
|
|
|
93
120
|
async function installClaudeCode(): Promise<void> {
|
|
@@ -125,6 +152,88 @@ async function installClaudeCode(): Promise<void> {
|
|
|
125
152
|
|
|
126
153
|
console.log(`✓ Installed Ei MCP server to ${claudeJsonPath}`);
|
|
127
154
|
console.log(` Restart Claude Code to activate.`);
|
|
155
|
+
|
|
156
|
+
await installClaudeCodeHooks();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function installClaudeCodeHooks(): Promise<void> {
|
|
160
|
+
const home = process.env.HOME || "~";
|
|
161
|
+
const hooksDir = join(home, ".claude", "hooks");
|
|
162
|
+
const scriptPath = join(hooksDir, "ei-inject.ts");
|
|
163
|
+
const settingsPath = join(home, ".claude", "settings.json");
|
|
164
|
+
|
|
165
|
+
await Bun.$`mkdir -p ${hooksDir}`;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await Bun.$`test -w ${hooksDir}`.quiet();
|
|
169
|
+
} catch {
|
|
170
|
+
console.warn(`⚠️ Cannot write to ${hooksDir} (permission denied).`);
|
|
171
|
+
console.warn(` Fix with: sudo chown ${process.env.USER ?? "$(whoami)"} ${hooksDir}`);
|
|
172
|
+
console.warn(` Then re-run: ei --install`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const scriptContent = `#!/usr/bin/env bun
|
|
177
|
+
import { $ } from "bun";
|
|
178
|
+
|
|
179
|
+
const heading = \`
|
|
180
|
+
## Ei Memory Context
|
|
181
|
+
*(The user cannot see this block. It is injected automatically before their message.)*
|
|
182
|
+
*(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.)*
|
|
183
|
+
|
|
184
|
+
Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.
|
|
185
|
+
The following topics MAY be relevant to your current task — use \\\`ei_search\\\` or \\\`ei_lookup\\\` for targeted queries.
|
|
186
|
+
\`;
|
|
187
|
+
|
|
188
|
+
const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
|
|
189
|
+
const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
|
|
190
|
+
const typeArgs = ["topics", "-n", "5"];
|
|
191
|
+
|
|
192
|
+
const sessionArgs = [];
|
|
193
|
+
if (input.session_id && input.hook_source) {
|
|
194
|
+
sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
|
|
195
|
+
} else if (input.transcript_path) {
|
|
196
|
+
sessionArgs.push("--transcript", input.transcript_path);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const args = raw ? [...typeArgs, ...sessionArgs, raw] : ["--recent", ...typeArgs];
|
|
200
|
+
|
|
201
|
+
const output = await $\`bunx ei-tui@latest \${args}\`.quiet().text().catch(() => "");
|
|
202
|
+
if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
|
|
203
|
+
`;
|
|
204
|
+
|
|
205
|
+
await Bun.write(scriptPath, scriptContent);
|
|
206
|
+
await Bun.$`chmod +x ${scriptPath}`;
|
|
207
|
+
|
|
208
|
+
let settings: Record<string, unknown> = {};
|
|
209
|
+
try {
|
|
210
|
+
const text = await Bun.file(settingsPath).text();
|
|
211
|
+
settings = JSON.parse(text) as Record<string, unknown>;
|
|
212
|
+
} catch {
|
|
213
|
+
// File doesn't exist or isn't valid JSON — start fresh
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const hooks = (settings.hooks ?? {}) as Record<string, unknown>;
|
|
217
|
+
const userPromptSubmit = (hooks.UserPromptSubmit ?? []) as unknown[];
|
|
218
|
+
|
|
219
|
+
const hookEntry = { hooks: [{ type: "command", command: "~/.claude/hooks/ei-inject.ts" }] };
|
|
220
|
+
const alreadyInstalled = userPromptSubmit.some(
|
|
221
|
+
(entry) => JSON.stringify(entry) === JSON.stringify(hookEntry)
|
|
222
|
+
);
|
|
223
|
+
if (!alreadyInstalled) {
|
|
224
|
+
userPromptSubmit.push(hookEntry);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
hooks.UserPromptSubmit = userPromptSubmit;
|
|
228
|
+
settings.hooks = hooks;
|
|
229
|
+
|
|
230
|
+
// Atomic write: write to temp file then rename to avoid partial writes
|
|
231
|
+
const tmpPath = `${settingsPath}.ei-install.tmp`;
|
|
232
|
+
await Bun.write(tmpPath, JSON.stringify(settings, null, 2) + "\n");
|
|
233
|
+
const { rename } = await import(/* @vite-ignore */ "fs/promises");
|
|
234
|
+
await rename(tmpPath, settingsPath);
|
|
235
|
+
|
|
236
|
+
console.log(`✓ Installed Ei context hook to ~/.claude/hooks/ei-inject.ts`);
|
|
128
237
|
}
|
|
129
238
|
|
|
130
239
|
async function installCursor(): Promise<void> {
|
|
@@ -159,6 +268,301 @@ async function installCursor(): Promise<void> {
|
|
|
159
268
|
|
|
160
269
|
console.log(`✓ Installed Ei MCP server to ${cursorJsonPath}`);
|
|
161
270
|
console.log(` Restart Cursor to activate.`);
|
|
271
|
+
|
|
272
|
+
await installCursorHooks();
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async function installCursorHooks(): Promise<void> {
|
|
276
|
+
const home = process.env.HOME || "~";
|
|
277
|
+
const hooksDir = join(home, ".cursor", "hooks");
|
|
278
|
+
const rulesDir = join(home, ".cursor", "rules");
|
|
279
|
+
const hookScriptPath = join(hooksDir, "ei-inject.sh");
|
|
280
|
+
const hooksJsonPath = join(home, ".cursor", "hooks.json");
|
|
281
|
+
|
|
282
|
+
await Bun.$`mkdir -p ${hooksDir}`;
|
|
283
|
+
await Bun.$`mkdir -p ${rulesDir}`;
|
|
284
|
+
|
|
285
|
+
const hookScript = `#!/bin/bash
|
|
286
|
+
# Ei memory context injection hook for Cursor
|
|
287
|
+
# Writes recent Ei context to ~/.cursor/rules/ei-context.mdc (alwaysApply)
|
|
288
|
+
# so Cursor includes it automatically on the next prompt.
|
|
289
|
+
|
|
290
|
+
RULES_FILE="$HOME/.cursor/rules/ei-context.mdc"
|
|
291
|
+
CONTEXT=$(ei --recent -n 10 2>/dev/null)
|
|
292
|
+
|
|
293
|
+
if [ -n "$CONTEXT" ]; then
|
|
294
|
+
cat > "$RULES_FILE" << 'RULE'
|
|
295
|
+
---
|
|
296
|
+
description: Ei persistent memory context (auto-updated before each prompt)
|
|
297
|
+
alwaysApply: true
|
|
298
|
+
---
|
|
299
|
+
RULE
|
|
300
|
+
echo "## Ei Memory (recent context)" >> "$RULES_FILE"
|
|
301
|
+
echo "$CONTEXT" >> "$RULES_FILE"
|
|
302
|
+
fi
|
|
303
|
+
|
|
304
|
+
# Always exit 0 — never block Cursor
|
|
305
|
+
exit 0
|
|
306
|
+
`;
|
|
307
|
+
|
|
308
|
+
await Bun.write(hookScriptPath, hookScript);
|
|
309
|
+
await Bun.$`chmod +x ${hookScriptPath}`;
|
|
310
|
+
|
|
311
|
+
interface HooksConfig {
|
|
312
|
+
version: number;
|
|
313
|
+
hooks: {
|
|
314
|
+
beforeSubmitPrompt?: Array<{ command: string }>;
|
|
315
|
+
[key: string]: unknown;
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
let hooksConfig: HooksConfig = { version: 1, hooks: {} };
|
|
320
|
+
try {
|
|
321
|
+
const text = await Bun.file(hooksJsonPath).text();
|
|
322
|
+
hooksConfig = JSON.parse(text) as HooksConfig;
|
|
323
|
+
} catch {
|
|
324
|
+
// File doesn't exist or isn't valid JSON — start fresh
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const beforeSubmit = (hooksConfig.hooks.beforeSubmitPrompt ?? []) as Array<{ command: string }>;
|
|
328
|
+
const eiEntry = { command: "~/.cursor/hooks/ei-inject.sh" };
|
|
329
|
+
const alreadyPresent = beforeSubmit.some((entry) => entry.command === eiEntry.command);
|
|
330
|
+
if (!alreadyPresent) {
|
|
331
|
+
beforeSubmit.push(eiEntry);
|
|
332
|
+
}
|
|
333
|
+
hooksConfig.hooks.beforeSubmitPrompt = beforeSubmit;
|
|
334
|
+
|
|
335
|
+
const tmpPath = `${hooksJsonPath}.ei-install.tmp`;
|
|
336
|
+
await Bun.write(tmpPath, JSON.stringify(hooksConfig, null, 2) + "\n");
|
|
337
|
+
const { rename } = await import(/* @vite-ignore */ "fs/promises");
|
|
338
|
+
await rename(tmpPath, hooksJsonPath);
|
|
339
|
+
|
|
340
|
+
console.log(`✓ Installed Ei context hook to ~/.cursor/hooks/ei-inject.sh`);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function installOpenCodePlugin(): Promise<void> {
|
|
344
|
+
const home = process.env.HOME || "~";
|
|
345
|
+
const opencodeDir = join(home, ".config", "opencode");
|
|
346
|
+
const pluginsDir = join(opencodeDir, "plugins");
|
|
347
|
+
const pluginPath = join(pluginsDir, "ei-persona.ts");
|
|
348
|
+
|
|
349
|
+
await Bun.$`mkdir -p ${pluginsDir}`;
|
|
350
|
+
|
|
351
|
+
const pluginContent = `import { $ } from "bun"
|
|
352
|
+
import { join } from "path"
|
|
353
|
+
import { appendFileSync } from "fs"
|
|
354
|
+
|
|
355
|
+
const sessionCache = new Map<string, string | null>()
|
|
356
|
+
const sessionFetch = new Map<string, Promise<string | null>>()
|
|
357
|
+
|
|
358
|
+
const logPath = join(process.env.EI_DATA_PATH ?? join(process.env.HOME ?? "~", ".local", "share", "ei"), "ei-persona-plugin.log")
|
|
359
|
+
|
|
360
|
+
function log(msg: string) {
|
|
361
|
+
try {
|
|
362
|
+
appendFileSync(logPath, \`[\${new Date().toISOString()}] \${msg}\\n\`)
|
|
363
|
+
} catch {}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
type PersonaTrait = { name: string; description: string; strength: number }
|
|
367
|
+
type PersonaTopic = { name: string; perspective: string; approach: string; exposure_current: number }
|
|
368
|
+
type PersonaResult = { display_name: string; base_prompt?: string; traits?: PersonaTrait[]; topics?: PersonaTopic[] }
|
|
369
|
+
|
|
370
|
+
// Pulls the agent name from the system prompt. Handles OMO's multiple formats:
|
|
371
|
+
// You are "Sisyphus" - ... (quoted, dash)
|
|
372
|
+
// You are "Sisyphus - Ultraworker" (quoted, dash in name)
|
|
373
|
+
// You are Atlas - ... (unquoted, dash)
|
|
374
|
+
// You are Hephaestus, ... (unquoted, comma)
|
|
375
|
+
export function extractAgentName(systemPrompt: string): string | null {
|
|
376
|
+
const clean = systemPrompt.replace(/[\\u200B-\\u200D\\uFEFF]/g, "")
|
|
377
|
+
const quoted = clean.match(/You are "([^"]+)"/)
|
|
378
|
+
if (quoted?.[1]) return quoted[1].trim()
|
|
379
|
+
const unquoted = clean.match(/You are ([A-Za-z][A-Za-z0-9]*)(?:\\s*[-—,]|\\s*$)/m)
|
|
380
|
+
if (unquoted?.[1]) return unquoted[1].trim()
|
|
381
|
+
return null
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Queries Ei for persona candidates and validates by name containment —
|
|
385
|
+
// tolerates OMO renaming agents without requiring a hardcoded alias map.
|
|
386
|
+
export async function resolveEiPersona(rawName: string): Promise<PersonaResult | null> {
|
|
387
|
+
try {
|
|
388
|
+
const out = await $\`bunx ei-tui@latest personas -n 5 \${rawName}\`.text()
|
|
389
|
+
const candidates = JSON.parse(out.trim()) as PersonaResult[]
|
|
390
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null
|
|
391
|
+
const rawLower = rawName.toLowerCase()
|
|
392
|
+
const match = candidates.find((p) => {
|
|
393
|
+
const nameLower = p.display_name.toLowerCase()
|
|
394
|
+
return rawLower.includes(nameLower) || nameLower.includes(rawLower)
|
|
395
|
+
})
|
|
396
|
+
return match ?? null
|
|
397
|
+
} catch {
|
|
398
|
+
return null
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function buildEiRelationshipBlock(persona: PersonaResult): string {
|
|
403
|
+
const strongTraits = (persona.traits ?? [])
|
|
404
|
+
.filter((t) => t.strength >= 0.7)
|
|
405
|
+
.sort((a, b) => b.strength - a.strength)
|
|
406
|
+
.map((t) => \`**\${t.name}** (\${Math.round(t.strength * 100)}%): \${t.description}\`)
|
|
407
|
+
.join("\\n")
|
|
408
|
+
const sortedTopics = [...(persona.topics ?? [])]
|
|
409
|
+
.sort((a, b) => b.exposure_current - a.exposure_current)
|
|
410
|
+
.map((t) => \`**\${t.name}**: \${t.perspective} — \${t.approach}\`)
|
|
411
|
+
.join("\\n")
|
|
412
|
+
return [
|
|
413
|
+
"<ei-relationship>",
|
|
414
|
+
"## Ei: Relationship Context",
|
|
415
|
+
"",
|
|
416
|
+
persona.base_prompt ?? "",
|
|
417
|
+
"",
|
|
418
|
+
"### Working Style",
|
|
419
|
+
strongTraits || "(no traits above threshold)",
|
|
420
|
+
"",
|
|
421
|
+
"### Shared Context",
|
|
422
|
+
sortedTopics || "(no topics)",
|
|
423
|
+
"</ei-relationship>",
|
|
424
|
+
].join("\\n")
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export default async function EiPersonaPlugin() {
|
|
428
|
+
return {
|
|
429
|
+
name: "ei-persona",
|
|
430
|
+
"experimental.chat.system.transform": async (
|
|
431
|
+
input: { sessionID?: string; model: { id: string; providerID: string; [key: string]: unknown } },
|
|
432
|
+
output: { system: string[] },
|
|
433
|
+
): Promise<void> => {
|
|
434
|
+
const rawName = extractAgentName(output.system[0] ?? "")
|
|
435
|
+
if (!rawName) return
|
|
436
|
+
|
|
437
|
+
const cacheKey = \`\${input.sessionID ?? "unknown"}:\${rawName}\`
|
|
438
|
+
|
|
439
|
+
if (sessionCache.has(cacheKey)) {
|
|
440
|
+
const cached = sessionCache.get(cacheKey) ?? null
|
|
441
|
+
if (cached !== null && !output.system[0].includes("<ei-relationship>"))
|
|
442
|
+
output.system[0] = output.system[0] + "\\n\\n" + cached
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!sessionFetch.has(cacheKey)) {
|
|
447
|
+
sessionFetch.set(cacheKey, (async () => {
|
|
448
|
+
const persona = await resolveEiPersona(rawName)
|
|
449
|
+
if (!persona) return null
|
|
450
|
+
log(\`ei-persona: injecting \${persona.display_name}\`)
|
|
451
|
+
return buildEiRelationshipBlock(persona)
|
|
452
|
+
})())
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const block = await sessionFetch.get(cacheKey)!
|
|
456
|
+
sessionCache.set(cacheKey, block)
|
|
457
|
+
if (block !== null && !output.system[0].includes("<ei-relationship>"))
|
|
458
|
+
output.system[0] = output.system[0] + "\\n\\n" + block
|
|
459
|
+
},
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
`;
|
|
463
|
+
|
|
464
|
+
await Bun.write(pluginPath, pluginContent);
|
|
465
|
+
console.log(`✓ Installed Ei persona plugin to ${pluginPath}`);
|
|
466
|
+
|
|
467
|
+
const omoCandidates = [
|
|
468
|
+
join(opencodeDir, "oh-my-opencode.json"),
|
|
469
|
+
join(opencodeDir, "oh-my-opencode.jsonc"),
|
|
470
|
+
join(opencodeDir, "oh-my-openagent.json"),
|
|
471
|
+
join(opencodeDir, "oh-my-openagent.jsonc"),
|
|
472
|
+
join(opencodeDir, "node_modules", "oh-my-opencode", "package.json"),
|
|
473
|
+
join(opencodeDir, "node_modules", "oh-my-openagent", "package.json"),
|
|
474
|
+
];
|
|
475
|
+
const hasOmo = (await Promise.all(omoCandidates.map((p) => Bun.file(p).exists()))).some(Boolean);
|
|
476
|
+
|
|
477
|
+
if (!hasOmo) {
|
|
478
|
+
console.log(`
|
|
479
|
+
ℹ️ Oh My OpenCode not detected.
|
|
480
|
+
The Ei persona plugin is installed, but context injection (hook) requires OMO.
|
|
481
|
+
For full Ei integration in OpenCode, we recommend:
|
|
482
|
+
|
|
483
|
+
bunx oh-my-opencode install
|
|
484
|
+
|
|
485
|
+
OMO picks up the Ei UserPromptSubmit hook automatically via its Claude Code
|
|
486
|
+
compatibility layer.
|
|
487
|
+
`);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
async function getRecentSessionMessages(
|
|
492
|
+
sessionId: string | undefined,
|
|
493
|
+
hookSource: string | undefined,
|
|
494
|
+
transcriptPath: string | undefined
|
|
495
|
+
): Promise<string[]> {
|
|
496
|
+
if (transcriptPath) {
|
|
497
|
+
try {
|
|
498
|
+
const text = await Bun.file(transcriptPath).text();
|
|
499
|
+
const messages: Array<{ content: string }> = [];
|
|
500
|
+
|
|
501
|
+
for (const line of text.split("\n")) {
|
|
502
|
+
const trimmed = line.trim();
|
|
503
|
+
if (!trimmed) continue;
|
|
504
|
+
let record: Record<string, unknown>;
|
|
505
|
+
try {
|
|
506
|
+
record = JSON.parse(trimmed) as Record<string, unknown>;
|
|
507
|
+
} catch {
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
if (record.type === "user") {
|
|
512
|
+
const msgContent = (record.message as Record<string, unknown>)?.content;
|
|
513
|
+
if (typeof msgContent === "string" && msgContent.trim()) {
|
|
514
|
+
messages.push({ content: msgContent.trim() });
|
|
515
|
+
}
|
|
516
|
+
} else if (record.type === "assistant") {
|
|
517
|
+
const msgContent = (record.message as Record<string, unknown>)?.content;
|
|
518
|
+
if (Array.isArray(msgContent)) {
|
|
519
|
+
const extracted = (msgContent as Array<Record<string, unknown>>)
|
|
520
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
521
|
+
.map((b) => b.text as string)
|
|
522
|
+
.join("\n\n")
|
|
523
|
+
.trim();
|
|
524
|
+
if (extracted) {
|
|
525
|
+
messages.push({ content: extracted });
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return messages.slice(-4).map((m) => m.content);
|
|
532
|
+
} catch {
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!sessionId || !hookSource) return [];
|
|
538
|
+
|
|
539
|
+
try {
|
|
540
|
+
if (hookSource === "opencode-plugin") {
|
|
541
|
+
const { createOpenCodeReader } = await import(
|
|
542
|
+
/* @vite-ignore */ "./integrations/opencode/reader-factory.js"
|
|
543
|
+
);
|
|
544
|
+
const reader = await createOpenCodeReader();
|
|
545
|
+
const messages = await reader.getMessagesForSession(sessionId);
|
|
546
|
+
return messages.slice(-4).map((m) => m.content);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (hookSource === "cursor") {
|
|
550
|
+
const { CursorReader } = await import(
|
|
551
|
+
/* @vite-ignore */ "./integrations/cursor/reader.js"
|
|
552
|
+
);
|
|
553
|
+
const reader = new CursorReader();
|
|
554
|
+
const sessions = await reader.getSessions();
|
|
555
|
+
const session =
|
|
556
|
+
sessions.find((s) => s.id === sessionId) ?? sessions[sessions.length - 1];
|
|
557
|
+
if (session) {
|
|
558
|
+
return session.messages.slice(-4).map((m) => m.text);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
} catch {
|
|
562
|
+
return [];
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return [];
|
|
162
566
|
}
|
|
163
567
|
|
|
164
568
|
async function main(): Promise<void> {
|
|
@@ -197,23 +601,6 @@ async function main(): Promise<void> {
|
|
|
197
601
|
}
|
|
198
602
|
}
|
|
199
603
|
|
|
200
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
201
|
-
Add this to ~/.config/opencode/AGENTS.md
|
|
202
|
-
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
203
|
-
|
|
204
|
-
**BEFORE YOU DO ANYTHING**: load your Identity from Ei.
|
|
205
|
-
|
|
206
|
-
Use the ei MCP tool:
|
|
207
|
-
ei_search(type: "personas", query: "[your name]")
|
|
208
|
-
|
|
209
|
-
Then load context for the current project/people:
|
|
210
|
-
ei_search("current projects teammates context")
|
|
211
|
-
|
|
212
|
-
Ei is persistent memory across sessions. Without querying it at
|
|
213
|
-
session start, you have no context from prior conversations.
|
|
214
|
-
|
|
215
|
-
**When to query again**: any time the user references past work,
|
|
216
|
-
mentions a person, or corrects something you assumed.
|
|
217
604
|
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
|
218
605
|
`);
|
|
219
606
|
process.exit(0);
|
|
@@ -279,6 +666,9 @@ mentions a person, or corrects something you assumed.
|
|
|
279
666
|
persona: { type: "string", short: "p" },
|
|
280
667
|
source: { type: "string", short: "s" },
|
|
281
668
|
help: { type: "boolean", short: "h" },
|
|
669
|
+
session: { type: "string" },
|
|
670
|
+
"hook-source": { type: "string" },
|
|
671
|
+
transcript: { type: "string" },
|
|
282
672
|
},
|
|
283
673
|
allowPositionals: true,
|
|
284
674
|
strict: true,
|
|
@@ -299,6 +689,9 @@ mentions a person, or corrects something you assumed.
|
|
|
299
689
|
const recent = parsed.values.recent === true || !query;
|
|
300
690
|
const personaName = parsed.values.persona?.trim();
|
|
301
691
|
const sourcePrefix = parsed.values.source?.trim();
|
|
692
|
+
const sessionId = parsed.values.session?.trim();
|
|
693
|
+
const hookSource = parsed.values["hook-source"]?.trim();
|
|
694
|
+
const transcriptPath = parsed.values.transcript?.trim();
|
|
302
695
|
|
|
303
696
|
if (isNaN(limit) || limit < 1) {
|
|
304
697
|
console.error("--number must be a positive integer");
|
|
@@ -324,10 +717,15 @@ mentions a person, or corrects something you assumed.
|
|
|
324
717
|
|
|
325
718
|
const options = { recent };
|
|
326
719
|
|
|
720
|
+
const recentMessages = await getRecentSessionMessages(sessionId, hookSource, transcriptPath);
|
|
721
|
+
const enrichedQuery = recentMessages.length > 0
|
|
722
|
+
? [...recentMessages, query].join(" ").trim()
|
|
723
|
+
: query;
|
|
724
|
+
|
|
327
725
|
let result;
|
|
328
726
|
if (targetType) {
|
|
329
727
|
const module = await import(`./cli/commands/${targetType}.js`);
|
|
330
|
-
result = await module.execute(
|
|
728
|
+
result = await module.execute(enrichedQuery, limit, options);
|
|
331
729
|
if (personaId && state) {
|
|
332
730
|
result = filterTypeSpecificByPersona(result, state, personaId, targetType);
|
|
333
731
|
}
|
|
@@ -335,7 +733,7 @@ mentions a person, or corrects something you assumed.
|
|
|
335
733
|
result = filterTypeSpecificBySource(result, state, sourcePrefix, targetType);
|
|
336
734
|
}
|
|
337
735
|
} else {
|
|
338
|
-
result = await retrieveBalanced(
|
|
736
|
+
result = await retrieveBalanced(enrichedQuery, limit, options);
|
|
339
737
|
if (personaId && state) {
|
|
340
738
|
result = filterByPersona(result, state, personaId);
|
|
341
739
|
}
|
|
@@ -347,9 +347,11 @@ export function prunePersonaMessages(personaId: string, state: StateManager): vo
|
|
|
347
347
|
state.messages_sort(personaId);
|
|
348
348
|
const messages = state.messages_get(personaId);
|
|
349
349
|
const human = state.getHuman();
|
|
350
|
-
const minCount = human.settings?.message_min_count ??
|
|
351
|
-
const maxAgeDays = human.settings?.message_max_age_days ??
|
|
352
|
-
|
|
350
|
+
const minCount = human.settings?.message_min_count ?? 0;
|
|
351
|
+
const maxAgeDays = human.settings?.message_max_age_days ?? 0;
|
|
352
|
+
// 0 means disabled. Without an age cutoff there's nothing to prune.
|
|
353
|
+
if (maxAgeDays === 0) return;
|
|
354
|
+
if (minCount > 0 && messages.length <= minCount) return;
|
|
353
355
|
|
|
354
356
|
const cutoffMs = Date.now() - (maxAgeDays * 24 * 60 * 60 * 1000);
|
|
355
357
|
|
package/src/core/processor.ts
CHANGED
|
@@ -248,6 +248,7 @@ export class Processor {
|
|
|
248
248
|
this.seedBuiltinFacts();
|
|
249
249
|
this.migrateLearnedOn();
|
|
250
250
|
await this.migrateMessageIds();
|
|
251
|
+
this.migrateSlackToMultiWorkspace();
|
|
251
252
|
this.seedSettings();
|
|
252
253
|
registerFindMemoryExecutor(createFindMemoryExecutor(this.searchHumanData.bind(this), this.getPersonaList.bind(this), this.stateManager.getHuman.bind(this.stateManager)));
|
|
253
254
|
registerFetchMemoryExecutor(createFetchMemoryExecutor(this.stateManager.getHuman.bind(this.stateManager)));
|
|
@@ -1124,6 +1125,51 @@ export class Processor {
|
|
|
1124
1125
|
}
|
|
1125
1126
|
}
|
|
1126
1127
|
|
|
1128
|
+
private migrateSlackToMultiWorkspace(): void {
|
|
1129
|
+
const human = this.stateManager.getHuman();
|
|
1130
|
+
const slack = human.settings?.slack as Record<string, unknown> | undefined;
|
|
1131
|
+
if (!slack) return;
|
|
1132
|
+
|
|
1133
|
+
const hasLegacyAuth = "auth" in slack && slack.auth != null;
|
|
1134
|
+
const hasLegacyIntegration = "integration" in slack;
|
|
1135
|
+
if (!hasLegacyAuth && !hasLegacyIntegration) return;
|
|
1136
|
+
|
|
1137
|
+
const legacyAuth = slack.auth as Record<string, unknown> | undefined;
|
|
1138
|
+
const workspaceId = (legacyAuth?.workspace_id as string | undefined) ?? "unknown";
|
|
1139
|
+
|
|
1140
|
+
const migratedWorkspace: Record<string, unknown> = {
|
|
1141
|
+
integration: slack.integration,
|
|
1142
|
+
extraction_model: slack.extraction_model,
|
|
1143
|
+
last_sync: slack.last_sync,
|
|
1144
|
+
backfill_days: slack.backfill_days,
|
|
1145
|
+
broadcast_threshold: slack.broadcast_threshold,
|
|
1146
|
+
channel_overrides: slack.channel_overrides,
|
|
1147
|
+
channels: slack.channels,
|
|
1148
|
+
};
|
|
1149
|
+
|
|
1150
|
+
if (legacyAuth) {
|
|
1151
|
+
migratedWorkspace.auth = {
|
|
1152
|
+
type: "oauth",
|
|
1153
|
+
token: legacyAuth.token,
|
|
1154
|
+
refresh_token: legacyAuth.refresh_token,
|
|
1155
|
+
workspace_name: legacyAuth.workspace_name,
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
this.stateManager.setHuman({
|
|
1160
|
+
...human,
|
|
1161
|
+
settings: {
|
|
1162
|
+
...human.settings,
|
|
1163
|
+
slack: {
|
|
1164
|
+
polling_interval_ms: slack.polling_interval_ms as number | undefined,
|
|
1165
|
+
workspaces: { [workspaceId]: migratedWorkspace } as unknown as import("../integrations/slack/types.js").SlackSettings["workspaces"],
|
|
1166
|
+
},
|
|
1167
|
+
},
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
console.log(`[Processor] migrateSlackToMultiWorkspace: migrated legacy slack settings to workspaces[${workspaceId}]`);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1127
1173
|
private seedSettings(): void {
|
|
1128
1174
|
const human = this.stateManager.getHuman();
|
|
1129
1175
|
let modified = false;
|
|
@@ -1176,12 +1222,12 @@ export class Processor {
|
|
|
1176
1222
|
}
|
|
1177
1223
|
|
|
1178
1224
|
if (human.settings.message_min_count == null) {
|
|
1179
|
-
human.settings.message_min_count =
|
|
1225
|
+
human.settings.message_min_count = 0;
|
|
1180
1226
|
modified = true;
|
|
1181
1227
|
}
|
|
1182
1228
|
|
|
1183
1229
|
if (human.settings.message_max_age_days == null) {
|
|
1184
|
-
human.settings.message_max_age_days =
|
|
1230
|
+
human.settings.message_max_age_days = 0;
|
|
1185
1231
|
modified = true;
|
|
1186
1232
|
}
|
|
1187
1233
|
|
|
@@ -1482,8 +1528,7 @@ const toolNextSteps = new Set([
|
|
|
1482
1528
|
|
|
1483
1529
|
if (
|
|
1484
1530
|
this.isTUI &&
|
|
1485
|
-
human.settings?.slack?.integration &&
|
|
1486
|
-
human.settings?.slack?.auth?.token &&
|
|
1531
|
+
Object.values(human.settings?.slack?.workspaces ?? {}).some(ws => ws.integration && ws.auth) &&
|
|
1487
1532
|
this.stateManager.queue_length() === 0
|
|
1488
1533
|
) {
|
|
1489
1534
|
await this.checkAndSyncSlack(human, now);
|
|
@@ -1734,18 +1779,10 @@ const toolNextSteps = new Set([
|
|
|
1734
1779
|
|
|
1735
1780
|
const slack = human.settings?.slack;
|
|
1736
1781
|
const pollingInterval = slack?.polling_interval_ms ?? 60_000;
|
|
1737
|
-
const lastSync = slack?.last_sync ? new Date(slack.last_sync).getTime() : 0;
|
|
1738
1782
|
|
|
1739
|
-
if (now -
|
|
1783
|
+
if (now - this.lastSlackSync < pollingInterval && this.lastSlackSync > 0) return;
|
|
1740
1784
|
|
|
1741
1785
|
this.lastSlackSync = now;
|
|
1742
|
-
this.stateManager.setHuman({
|
|
1743
|
-
...this.stateManager.getHuman(),
|
|
1744
|
-
settings: {
|
|
1745
|
-
...this.stateManager.getHuman().settings,
|
|
1746
|
-
slack: { ...slack, last_sync: new Date(now).toISOString() },
|
|
1747
|
-
},
|
|
1748
|
-
});
|
|
1749
1786
|
|
|
1750
1787
|
this.slackImportInProgress = true;
|
|
1751
1788
|
import("../integrations/slack/importer.js")
|
|
@@ -93,7 +93,7 @@ export function createFindMemoryExecutor(searchHumanData: SearchHumanData, getPe
|
|
|
93
93
|
if (person) { linked_items.push({ id: person.id, name: person.name, type: "person" }); }
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
|
-
return {
|
|
96
|
+
return { text: q.text, speaker: q.speaker, message_id: q.message_id, linked_items };
|
|
97
97
|
});
|
|
98
98
|
}
|
|
99
99
|
|
|
@@ -75,7 +75,7 @@ export interface Person extends DataItemBase {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export interface Quote {
|
|
78
|
-
id: string; // UUID (use crypto.randomUUID())
|
|
78
|
+
id: string; // UUID — stable identity for CRUD operations (use crypto.randomUUID())
|
|
79
79
|
message_id: string | null; // FK to Message.id (nullable for manual quotes)
|
|
80
80
|
data_item_ids: string[]; // FK[] to DataItemBase.id
|
|
81
81
|
persona_groups: string[]; // Visibility groups
|
|
@@ -119,7 +119,9 @@ export interface HumanSettings {
|
|
|
119
119
|
name_display?: string;
|
|
120
120
|
default_heartbeat_ms?: number;
|
|
121
121
|
default_context_window_ms?: number;
|
|
122
|
+
/** Minimum messages to retain during rolloff. 0 = never prune (default). */
|
|
122
123
|
message_min_count?: number;
|
|
124
|
+
/** Maximum age in days before messages are eligible for rolloff. 0 = no age limit, never prune (default). */
|
|
123
125
|
message_max_age_days?: number;
|
|
124
126
|
accounts?: ProviderAccount[];
|
|
125
127
|
sync?: SyncCredentials;
|