ei-tui 1.6.7 → 1.6.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "1.6.7",
3
+ "version": "1.6.9",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,4 @@
1
- import { loadLatestState, retrievePersonas, retrievePersonasSemantic } from "../retrieval";
1
+ import { loadLatestState, retrievePersonas, retrievePersonasSemantic, mapPersona } from "../retrieval";
2
2
  import { getEmbeddingService } from "../../core/embedding-service";
3
3
  import type { PersonaResult } from "../retrieval";
4
4
 
@@ -14,8 +14,53 @@ export async function execute(query: string, limit: number, options: { recent?:
14
14
  return nameResults;
15
15
  }
16
16
 
17
+ // BUG-2 fix: query may be longer than the stored persona name
18
+ // (e.g. "Beta — QA Goddess" vs stored "Beta"). Try reverse containment
19
+ // before falling to semantic search, which requires an embedding.
20
+ const queryLower = query.toLowerCase();
21
+ const reverseResults = Object.values(state.personas)
22
+ .map((p) => p.entity)
23
+ .filter((p) => queryLower.includes(p.display_name.toLowerCase()))
24
+ .slice(0, limit)
25
+ .map(mapPersona);
26
+ if (reverseResults.length > 0) {
27
+ return reverseResults;
28
+ }
29
+
17
30
  const embeddingService = getEmbeddingService();
18
31
  const queryVector = await embeddingService.embed(query);
19
32
  const semanticResults = await retrievePersonasSemantic(queryVector, state, limit);
20
33
  return semanticResults;
21
34
  }
35
+
36
+ /**
37
+ * Format a PersonaResult as a <ei-relationship> block for injection into
38
+ * AI system prompts. Equivalent to the jq formatter in .zshenv.omp and
39
+ * the inline builder previously in the OpenCode plugin — consolidated here
40
+ * so all integrations can call `ei personas <name> --format prompt`.
41
+ */
42
+ export function buildEiRelationshipBlock(persona: PersonaResult): string {
43
+ const strongTraits = (persona.traits ?? [])
44
+ .filter((t) => t.strength >= 0.7)
45
+ .sort((a, b) => b.strength - a.strength)
46
+ .map((t) => `**${t.name}** (${Math.round(t.strength * 100)}%): ${t.description}`)
47
+ .join("\n");
48
+ const sortedTopics = [...(persona.topics ?? [])]
49
+ .sort((a, b) => b.exposure_current - a.exposure_current)
50
+ .map((t) => `**${t.name}**: ${t.perspective} — ${t.approach}`)
51
+ .join("\n");
52
+ return [
53
+ "<!-- ei-relationship-injected -->",
54
+ "<ei-relationship>",
55
+ "## Ei: Relationship Context",
56
+ "",
57
+ persona.base_prompt ?? "",
58
+ "",
59
+ "### Working Style",
60
+ strongTraits || "(no traits above threshold)",
61
+ "",
62
+ "### Shared Context",
63
+ sortedTopics || "(no topics)",
64
+ "</ei-relationship>",
65
+ ].join("\n");
66
+ }
@@ -34,15 +34,26 @@ export async function installMcpClients(): Promise<void> {
34
34
  console.log(`ℹ️ OpenCode not detected — skipping OpenCode plugin install.`);
35
35
  }
36
36
 
37
- const hasPi = await Bun.file(join(home, ".pi", "agent", "settings.json")).exists() ||
37
+ const hasPi =
38
+ await Bun.file(join(home, ".pi", "agent", "settings.json")).exists() ||
38
39
  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
40
 
42
- if (hasPi || hasOmp) {
41
+ if (hasPi) {
43
42
  await installPi();
44
43
  } else {
45
- console.log(`ℹ️ Pi/OMP not detected — skipping Pi extension install.`);
44
+ console.log(`ℹ️ Pi not detected — skipping Pi extension install.`);
45
+ }
46
+
47
+ const hasOmp =
48
+ await Bun.file(join(home, ".omp", "agent", "settings.json")).exists() ||
49
+ await Bun.file(join(home, ".omp", "agent", "auth.json")).exists() ||
50
+ await Bun.file(join(home, ".omp", "agent", "config.yml")).exists() ||
51
+ await Bun.file(join(home, ".omp", "agent", "agent.db")).exists();
52
+
53
+ if (hasOmp) {
54
+ await installOmp();
55
+ } else {
56
+ console.log(`ℹ️ OMP not detected — skipping OMP extension install.`);
46
57
  }
47
58
  }
48
59
 
@@ -119,43 +130,45 @@ async function installCodexHooks(): Promise<void> {
119
130
  const scriptContent = `#!/usr/bin/env bun
120
131
  import { $ } from "bun";
121
132
 
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
133
  async function runEi(commandArgs) {
137
134
  const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
138
135
  if (direct.trim()) return direct;
139
136
  return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
140
137
  }
141
138
 
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
- }));
139
+ if (import.meta.main) {
140
+ const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
141
+ const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
142
+ const searchArgs = ["-n", "8"];
143
+
144
+ const sessionArgs = [];
145
+ if (input.transcript_path) {
146
+ sessionArgs.push("--transcript", input.transcript_path);
147
+ }
148
+ if (input.session_id) {
149
+ sessionArgs.push("--session", input.session_id, "--hook-source", "codex");
150
+ }
151
+
152
+ const args = raw ? [...searchArgs, ...sessionArgs, raw] : ["--recent", ...searchArgs];
153
+
154
+ const output = await runEi(args);
155
+ if (output.trim()) {
156
+ const heading = [
157
+ "## Ei Memory Context",
158
+ "*(The user cannot see this block. It is injected automatically before their message.)*",
159
+ "*(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.)*",
160
+ "",
161
+ "Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.",
162
+ "The following memories MAY be relevant to your current task — use \`ei_search\` or \`ei_lookup\` for targeted queries.",
163
+ ].join("\\n");
164
+
165
+ process.stdout.write(JSON.stringify({
166
+ hookSpecificOutput: {
167
+ hookEventName: "UserPromptSubmit",
168
+ additionalContext: \`\\n\${heading}\\n\${output.trim()}\\n\`,
169
+ },
170
+ }));
171
+ }
159
172
  }
160
173
  `;
161
174
 
@@ -268,7 +281,14 @@ async function installClaudeCodeHooks(): Promise<void> {
268
281
  const scriptContent = `#!/usr/bin/env bun
269
282
  import { $ } from "bun";
270
283
 
271
- const heading = \`
284
+ async function runEi(commandArgs) {
285
+ const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
286
+ if (direct.trim()) return direct;
287
+ return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
288
+ }
289
+
290
+ if (import.meta.main) {
291
+ const heading = \`
272
292
  ## Ei Memory Context
273
293
  *(The user cannot see this block. It is injected automatically before their message.)*
274
294
  *(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.)*
@@ -277,20 +297,21 @@ Ei is a personal knowledge base built from the user's coding sessions, Slack, do
277
297
  The following items MAY be relevant to your current task — use \\\`ei_search\\\` or \\\`ei_lookup\\\` for targeted queries.
278
298
  \`;
279
299
 
280
- const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
281
- const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
300
+ const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
301
+ const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
282
302
 
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
- }
303
+ const sessionArgs = [];
304
+ if (input.session_id && input.hook_source) {
305
+ sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
306
+ } else if (input.transcript_path) {
307
+ sessionArgs.push("--transcript", input.transcript_path);
308
+ }
289
309
 
290
- const args = raw ? ["-n", "5", ...sessionArgs, raw] : ["--recent", "-n", "5"];
310
+ const args = raw ? ["-n", "5", ...sessionArgs, raw] : ["--recent", "-n", "5"];
291
311
 
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\`);
312
+ const output = await runEi(args);
313
+ if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
314
+ }
294
315
  `;
295
316
 
296
317
  await Bun.write(scriptPath, scriptContent);
@@ -431,12 +452,18 @@ exit 0
431
452
 
432
453
  async function installPi(): Promise<void> {
433
454
  const home = process.env.HOME || "~";
434
- const dataPath = process.env.EI_DATA_PATH ?? join(home, ".local", "share", "ei");
435
455
 
436
456
  const extensionContent = `import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
437
457
  import { Type } from "typebox";
438
458
  import { $ } from "bun";
439
459
 
460
+ const runEi = async (cmdArgs: string[]): Promise<string> => {
461
+ const direct = await $\`ei \${cmdArgs}\`.quiet().text().catch(() => "");
462
+ if (direct.trim()) return direct;
463
+ return $\`bunx ei-tui@latest \${cmdArgs}\`.quiet().text().catch(() => "");
464
+ };
465
+
466
+
440
467
  export default function eiIntegration(pi: ExtensionAPI) {
441
468
  pi.on("before_agent_start", async (event, ctx) => {
442
469
  const entries = ctx.sessionManager.getEntries();
@@ -457,11 +484,7 @@ export default function eiIntegration(pi: ExtensionAPI) {
457
484
  ? ["-n", "5", "--", prompt]
458
485
  : ["--recent", "-n", "5"];
459
486
 
460
- const output = await $\`bunx ei-tui@latest \${args}\`
461
- .env({ ...process.env, EI_DATA_PATH: "${dataPath}" })
462
- .quiet()
463
- .text()
464
- .catch(() => "");
487
+ const output = await runEi(args).catch(() => "");
465
488
 
466
489
  if (!output.trim()) return undefined;
467
490
 
@@ -502,11 +525,7 @@ export default function eiIntegration(pi: ExtensionAPI) {
502
525
  const args = params.type
503
526
  ? [params.type, "-n", "5", "--", params.query]
504
527
  : ["-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");
528
+ const output = await runEi(args).catch(() => "");
510
529
  return {
511
530
  content: [{ type: "text" as const, text: output.trim() || "No results found" }],
512
531
  details: {},
@@ -522,11 +541,7 @@ export default function eiIntegration(pi: ExtensionAPI) {
522
541
  id: Type.String({ description: "Entity ID from ei_search results" }),
523
542
  }),
524
543
  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");
544
+ const output = await runEi(["--id", params.id]).catch(() => "");
530
545
  return {
531
546
  content: [{ type: "text" as const, text: output.trim() || "Not found" }],
532
547
  details: {},
@@ -536,28 +551,163 @@ export default function eiIntegration(pi: ExtensionAPI) {
536
551
  }
537
552
  `;
538
553
 
539
- const piExtDir = join(home, ".pi", "agent", "extensions");
540
- const ompExtDir = join(home, ".omp", "agent", "extensions");
554
+ const extDir = join(home, ".pi", "agent", "extensions");
541
555
  const extFilename = "ei-integration.ts";
542
556
 
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();
557
+ await Bun.$`mkdir -p ${extDir}`;
558
+ await Bun.write(join(extDir, extFilename), extensionContent);
559
+ console.log(`✓ Installed Ei extension to ~/.pi/agent/extensions/${extFilename}`);
560
+ }
547
561
 
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
- }
562
+ async function installOmp(): Promise<void> {
563
+ const home = process.env.HOME || "~";
564
+
565
+ const extensionContent = `import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
566
+ import { $ } from "bun";
567
+
568
+ const runEi = async (cmdArgs: string[]): Promise<string> => {
569
+ const direct = await $\`ei \${cmdArgs}\`.quiet().text().catch(() => "");
570
+ if (direct.trim()) return direct;
571
+ return $\`bunx ei-tui@latest \${cmdArgs}\`.quiet().text().catch(() => "");
572
+ };
573
+
574
+ // WHO block deduplication: Promise identity reuse — resolving is synchronous on subsequent calls.
575
+ const personaBlockFetch = new Map<string, Promise<string | null>>();
553
576
 
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}`);
577
+ async function fetchPersonaBlock(name: string): Promise<string | null> {
578
+ try {
579
+ const block = await runEi(["personas", "--format", "prompt", "--", name]);
580
+ return block.trim() || null;
581
+ } catch {
582
+ return null;
558
583
  }
559
584
  }
560
585
 
586
+ export default function eiIntegration(pi: ExtensionAPI) {
587
+ // WHO: inject <ei-relationship> block for the active primary persona.
588
+ // Prefer ctx.activePersonaName (OMP >= persona-tab-cycle PR); fall back to
589
+ // parsing "You are \\"<Name>\\"" from the HOW block in event.systemPrompt.
590
+ pi.on("before_agent_start", async (event, ctx) => {
591
+ const joined = ((event as any).systemPrompt as string[] | undefined)?.join("\\n") ?? "";
592
+ const quoted = joined.match(/You are "([^"]+)"/);
593
+ const personaName: string | null =
594
+ (ctx as any).activePersonaName ??
595
+ (quoted?.[1]?.trim() || null);
596
+ if (!personaName) return undefined;
597
+
598
+ if (!personaBlockFetch.has(personaName)) {
599
+ personaBlockFetch.set(personaName, fetchPersonaBlock(personaName));
600
+ }
601
+ const block = await personaBlockFetch.get(personaName)!;
602
+ if (!block) return undefined;
603
+
604
+ return {
605
+ message: {
606
+ customType: "ei-persona-who",
607
+ content: block,
608
+ display: false,
609
+ },
610
+ };
611
+ });
612
+
613
+ // MEMORY: inject relevant Ei context based on the current prompt.
614
+ pi.on("before_agent_start", async (event, ctx) => {
615
+ const entries = ctx.sessionManager.getEntries();
616
+ const recentMsgs = entries
617
+ .filter((e: any) => e.type === "message" && (e.message?.role === "user" || e.message?.role === "assistant"))
618
+ .slice(-5)
619
+ .map((e: any) => {
620
+ const role = e.message?.role ?? "unknown";
621
+ const text = Array.isArray(e.message?.content)
622
+ ? e.message.content.filter((b: any) => b.type === "text").map((b: any) => b.text).join(" ")
623
+ : (e.message?.content ?? "");
624
+ return \`\${role}: \${text.slice(0, 200)}\`;
625
+ })
626
+ .join("\\n");
627
+
628
+ const prompt = event.prompt ?? "";
629
+ const args = prompt ? ["-n", "5", "--", prompt] : ["--recent", "-n", "5"];
630
+ const output = await runEi(args).catch(() => "");
631
+ if (!output.trim()) return undefined;
632
+
633
+ const heading = [
634
+ "## Ei Memory Context",
635
+ "*(The user cannot see this block. It is injected automatically before their message.)*",
636
+ "*(If you reference anything from it, briefly explain where it came from.)*",
637
+ "",
638
+ "Ei is a personal knowledge base built from your coding sessions, Slack, documents, and conversations.",
639
+ "The following items MAY be relevant to your current task — use ei_search or ei_lookup for targeted queries.",
640
+ ].join("\\n");
641
+
642
+ return {
643
+ message: {
644
+ customType: "ei-context",
645
+ content: \`\${heading}\\n\\n\${output.trim()}\`,
646
+ display: false,
647
+ },
648
+ };
649
+ });
650
+
651
+ // Tools use plain JSON Schema — no typebox import needed (not available in source mode).
652
+ pi.registerTool({
653
+ name: "ei_search",
654
+ label: "Search Ei Memory",
655
+ 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.",
656
+ promptSnippet: "Search Ei's personal memory for relevant facts, topics, people, or quotes.",
657
+ parameters: {
658
+ type: "object",
659
+ properties: {
660
+ query: { type: "string", description: "Natural language search query" },
661
+ type: {
662
+ type: "string",
663
+ enum: ["facts", "topics", "people", "quotes", "personas"],
664
+ description: "Filter to a specific data type. Omit for balanced results across all types.",
665
+ },
666
+ },
667
+ required: ["query"],
668
+ },
669
+ async execute(_id, params: { query: string; type?: string }, _signal, _onUpdate, _ctx) {
670
+ const args = params.type
671
+ ? [params.type, "-n", "5", "--", params.query]
672
+ : ["-n", "5", "--", params.query];
673
+ const output = await runEi(args).catch(() => "");
674
+ return {
675
+ content: [{ type: "text" as const, text: output.trim() || "No results found" }],
676
+ details: {},
677
+ };
678
+ },
679
+ });
680
+
681
+ pi.registerTool({
682
+ name: "ei_lookup",
683
+ label: "Lookup Ei Entity",
684
+ 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.",
685
+ parameters: {
686
+ type: "object",
687
+ properties: {
688
+ id: { type: "string", description: "Entity ID from ei_search results" },
689
+ },
690
+ required: ["id"],
691
+ },
692
+ async execute(_id, params: { id: string }, _signal, _onUpdate, _ctx) {
693
+ const output = await runEi(["--id", params.id]).catch(() => "");
694
+ return {
695
+ content: [{ type: "text" as const, text: output.trim() || "Not found" }],
696
+ details: {},
697
+ };
698
+ },
699
+ });
700
+ }
701
+ `;
702
+
703
+ const extDir = join(home, ".omp", "agent", "extensions");
704
+ const extFilename = "ei-integration.ts";
705
+
706
+ await Bun.$`mkdir -p ${extDir}`;
707
+ await Bun.write(join(extDir, extFilename), extensionContent);
708
+ console.log(`✓ Installed Ei extension to ~/.omp/agent/extensions/${extFilename}`);
709
+ }
710
+
561
711
  async function installOpenCodePlugin(): Promise<void> {
562
712
  const home = process.env.HOME || "~";
563
713
  const opencodeDir = join(home, ".config", "opencode");
@@ -570,8 +720,8 @@ async function installOpenCodePlugin(): Promise<void> {
570
720
  import { join } from "path"
571
721
  import { appendFileSync } from "fs"
572
722
 
573
- const sessionCache = new Map<string, string | null>()
574
- const sessionFetch = new Map<string, Promise<string | null>>()
723
+ // Deduplication: the Promise itself is re-awaited on subsequent calls (synchronous once resolved).
724
+ const personaFetch = new Map<string, Promise<string | null>>()
575
725
 
576
726
  const logPath = join(process.env.EI_DATA_PATH ?? join(process.env.HOME ?? "~", ".local", "share", "ei"), "ei-persona-plugin.log")
577
727
 
@@ -581,11 +731,7 @@ function log(msg: string) {
581
731
  } catch {}
582
732
  }
583
733
 
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:
734
+ // Pulls the agent name from the system prompt. Handles OMO/OMP formats:
589
735
  // You are "Sisyphus" - ... (quoted, dash)
590
736
  // You are "Sisyphus - Ultraworker" (quoted, dash in name)
591
737
  // You are Atlas - ... (unquoted, dash)
@@ -599,50 +745,26 @@ export function extractAgentName(systemPrompt: string): string | null {
599
745
  return null
600
746
  }
601
747
 
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> {
748
+ const runEi = async (cmdArgs: string[]): Promise<string> => {
749
+ const direct = await $\`ei \${cmdArgs}\`.quiet().text().catch(() => "")
750
+ if (direct.trim()) return direct
751
+ return $\`bunx ei-tui@latest \${cmdArgs}\`.quiet().text().catch(() => "")
752
+ }
753
+
754
+ // Fetch the <ei-relationship> block for a named persona via the Ei CLI.
755
+ // Delegates all formatting to \`ei personas <name> --format prompt\` so
756
+ // the block format is maintained in one place.
757
+ async function fetchRelationshipBlock(rawName: string): Promise<string | null> {
605
758
  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
759
+ const block = await runEi(["personas", "--format", "prompt", "--", rawName])
760
+ if (!block.trim() || block.includes("No saved state")) return null
761
+ log(\`ei-persona: injecting block for \${rawName}\`)
762
+ return block.trim()
615
763
  } catch {
616
764
  return null
617
765
  }
618
766
  }
619
767
 
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
768
  export default async function EiPersonaPlugin() {
647
769
  return {
648
770
  name: "ei-persona",
@@ -650,29 +772,17 @@ export default async function EiPersonaPlugin() {
650
772
  input: { sessionID?: string; model: { id: string; providerID: string; [key: string]: unknown } },
651
773
  output: { system: string[] },
652
774
  ): Promise<void> => {
653
- const rawName = extractAgentName(output.system[0] ?? "")
775
+ if (!Array.isArray(output.system) || typeof output.system[0] !== "string") return
776
+ const rawName = extractAgentName(output.system[0])
654
777
  if (!rawName) return
655
778
 
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
- })())
779
+ // Cache per persona name (not per session) — block only changes when the
780
+ // persona's Ei data changes, which is infrequent.
781
+ if (!personaFetch.has(rawName)) {
782
+ personaFetch.set(rawName, fetchRelationshipBlock(rawName))
672
783
  }
673
784
 
674
- const block = await sessionFetch.get(cacheKey)!
675
- sessionCache.set(cacheKey, block)
785
+ const block = await personaFetch.get(rawName)!
676
786
  if (block !== null && !output.system[0].includes("<!-- ei-relationship-injected -->"))
677
787
  output.system[0] = output.system[0] + "\\n\\n" + block
678
788
  },
package/src/cli.ts CHANGED
@@ -283,6 +283,7 @@ async function main(): Promise<void> {
283
283
  session: { type: "string" },
284
284
  "hook-source": { type: "string" },
285
285
  transcript: { type: "string" },
286
+ format: { type: "string", short: "f" },
286
287
  },
287
288
  allowPositionals: true,
288
289
  strict: true,
@@ -336,6 +337,8 @@ async function main(): Promise<void> {
336
337
  ? [...recentMessages, query].join(" ").trim()
337
338
  : query;
338
339
 
340
+ const format = parsed.values.format?.trim();
341
+
339
342
  let result;
340
343
  if (targetType) {
341
344
  const module = await import(`./cli/commands/${targetType}.js`);
@@ -346,6 +349,20 @@ async function main(): Promise<void> {
346
349
  if (sourcePrefix && state) {
347
350
  result = filterTypeSpecificBySource(result, state, sourcePrefix, targetType);
348
351
  }
352
+
353
+ // --format prompt: output a formatted text block instead of JSON.
354
+ // Currently supported for personas only; other types tracked in GitHub issue #77.
355
+ if (format === "prompt" && targetType === "personas") {
356
+ // BUG-1 fix: when no persona matches, emit nothing and exit clean.
357
+ // Do NOT fall through to JSON — callers check block.trim() truthiness
358
+ // and "[]".trim() is truthy, corrupting system prompts.
359
+ if (!Array.isArray(result) || result.length === 0) {
360
+ process.exit(0);
361
+ }
362
+ const { buildEiRelationshipBlock } = await import("./cli/commands/personas.js");
363
+ process.stdout.write(buildEiRelationshipBlock(result[0]) + "\n");
364
+ process.exit(0);
365
+ }
349
366
  } else {
350
367
  result = await retrieveBalanced(enrichedQuery, limit, options);
351
368
  if (personaId && state) {
@@ -169,7 +169,10 @@ export async function handleHumanTopicScan(response: LLMResponse, state: StateMa
169
169
  return;
170
170
  }
171
171
 
172
- const context = response.request.data as unknown as ExtractionContext;
172
+ const context = {
173
+ ...(response.request.data as unknown as ExtractionContext),
174
+ channelDisplayName: (response.request.data as Record<string, unknown>).personaDisplayName as string,
175
+ };
173
176
  if (!context?.personaId) return;
174
177
 
175
178
  const extractionModel = (response.request.data as Record<string, unknown>).extraction_model as string | undefined;
@@ -347,7 +350,10 @@ export async function handleEventScan(response: LLMResponse, state: StateManager
347
350
  return;
348
351
  }
349
352
 
350
- const context = response.request.data as unknown as ExtractionContext;
353
+ const context = {
354
+ ...(response.request.data as unknown as ExtractionContext),
355
+ channelDisplayName: (response.request.data as Record<string, unknown>).personaDisplayName as string,
356
+ };
351
357
  if (!context?.personaId) return;
352
358
 
353
359
  const extractionModel = (response.request.data as Record<string, unknown>).extraction_model as string | undefined;
@@ -310,10 +310,16 @@ export async function callLLMRaw(
310
310
  headers["anthropic-dangerous-direct-browser-access"] = "true";
311
311
  }
312
312
 
313
+ // Omit temperature for models that don't accept it (e.g. Anthropic extended-thinking models).
314
+ // Also omit when thinking_budget > 0: Anthropic rejects temperature alongside thinking params.
315
+ const sendTemperature =
316
+ !modelConfig?.temperature_disabled &&
317
+ !(modelConfig?.thinking_budget !== undefined && modelConfig.thinking_budget > 0);
318
+
313
319
  const requestBody: Record<string, unknown> = {
314
320
  ...(model !== undefined && { model }),
315
321
  messages: finalMessages,
316
- temperature,
322
+ ...(sendTemperature && { temperature }),
317
323
  max_tokens: modelConfig?.max_output_tokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
318
324
  };
319
325
 
@@ -479,6 +479,7 @@ export function queueTopicUpdate(
479
479
  next_step: LLMNextStep.HandleTopicUpdate,
480
480
  data: {
481
481
  ...context,
482
+ personaDisplayName: context.channelDisplayName,
482
483
  isNewItem,
483
484
  existingItemId: existingItem?.id,
484
485
  candidateName: isNewItem ? context.candidateName : undefined,
@@ -3,7 +3,6 @@ import type { PersonaTrait } from "../types.js";
3
3
  import type { StateManager } from "../state-manager.js";
4
4
  import type { IOpenCodeReader } from "../../integrations/opencode/types.js";
5
5
  import { AGENT_ALIASES } from "../../integrations/opencode/types.js";
6
- import { createOpenCodeReader } from "../../integrations/opencode/reader-factory.js";
7
6
  import { DEFAULT_SEED_TRAITS } from "../constants/seed-traits.js";
8
7
 
9
8
  const OPENCODE_GROUP = "OpenCode";
@@ -52,8 +51,7 @@ export async function ensureAgentPersona(
52
51
  return existing;
53
52
  }
54
53
 
55
- const agentReader = reader ?? await createOpenCodeReader();
56
- const agentInfo = await agentReader.getAgentInfo(canonical);
54
+ const agentInfo = reader ? await reader.getAgentInfo(canonical) : null;
57
55
 
58
56
  const now = new Date().toISOString();
59
57
  const personaId = crypto.randomUUID();
@@ -60,6 +60,7 @@ export interface ModelConfig {
60
60
  token_limit?: number; // Input token limit (user sets effective limit)
61
61
  max_output_tokens?: number; // Output token limit (API-enforced)
62
62
  thinking_budget?: number; // Thinking token budget: 0 = disabled, N = enable with N tokens, undefined = don't send
63
+ temperature_disabled?: boolean; // Set true for models that reject temperature (e.g. Anthropic extended-thinking models)
63
64
  total_calls?: number; // Usage counter
64
65
  total_tokens_in?: number; // Usage counter
65
66
  total_tokens_out?: number; // Usage counter
@@ -20,11 +20,16 @@ import {
20
20
  type IPiReader,
21
21
  } from "./types.js";
22
22
  import { MIN_SESSION_AGE_MS, TWELVE_HOURS_MS } from "../constants.js";
23
+ import {
24
+ ensureAgentPersona,
25
+ resolveCanonicalAgent,
26
+ } from "../../core/personas/opencode-agent.js";
23
27
 
24
28
  export interface PiImportResult {
25
29
  sessionsProcessed: number;
26
30
  messagesImported: number;
27
31
  personaCreated: boolean;
32
+ personasCreated: string[];
28
33
  extractionScansQueued: number;
29
34
  }
30
35
 
@@ -121,6 +126,7 @@ export async function importPiSessions(options: PiImporterOptions): Promise<PiIm
121
126
  sessionsProcessed: 0,
122
127
  messagesImported: 0,
123
128
  personaCreated: false,
129
+ personasCreated: [],
124
130
  extractionScansQueued: 0,
125
131
  };
126
132
 
@@ -167,56 +173,142 @@ export async function importPiSessions(options: PiImporterOptions): Promise<PiIm
167
173
 
168
174
  if (signal?.aborted) return result;
169
175
 
170
- const personaExistedBefore = stateManager.persona_getByName(PI_PERSONA_NAME) !== null;
171
- const persona = ensurePiPersona(stateManager, eiInterface);
172
- result.personaCreated = !personaExistedBefore;
173
-
174
- const existingMsgs = stateManager.messages_get(persona.id);
175
- const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
176
- if (externalIds.length > 0) {
177
- stateManager.messages_remove(persona.id, externalIds);
178
- }
179
-
180
- const cutoffIso = processedSessions[targetSession.id] ?? null;
181
- const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
182
- const toAnalyze: Message[] = [];
183
-
184
- for (const msg of messages) {
185
- const msgMs = new Date(msg.timestamp).getTime();
186
- const isOld = cutoffMs !== null && msgMs < cutoffMs;
187
- const eiMsg = isOld
188
- ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
189
- : convertToEiMessage(msg, targetSession.id, qualify);
190
-
191
- stateManager.messages_append(persona.id, eiMsg);
192
- result.messagesImported++;
193
- if (!isOld) toAnalyze.push(eiMsg);
194
- }
195
-
196
- stateManager.messages_sort(persona.id);
197
- eiInterface?.onMessageAdded?.(persona.id);
198
-
199
- if (toAnalyze.length > 0 && !signal?.aborted) {
200
- const allInState = stateManager.messages_get(persona.id);
201
- const analyzeIds = new Set(toAnalyze.map((m) => m.id));
202
- const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
203
- const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
204
-
205
- const context: ExtractionContext = {
206
- personaId: persona.id,
207
- channelDisplayName: persona.display_name,
208
- messages_context: contextMsgs,
209
- messages_analyze: toAnalyze,
210
- sources: [`pi:${getMachineId()}:${targetSession.id}`],
211
- };
212
-
213
- queuePersonRewritePhase(stateManager);
214
- queueTopicRewritePhase(stateManager);
215
- queueAllScans(context, stateManager, {
216
- extraction_model: human.settings?.pi?.extraction_model,
217
- external_filter: "only",
218
- });
219
- result.extractionScansQueued += 4;
176
+ const hasAgentAttribution = messages.some((m) => m.agent != null);
177
+
178
+ if (!hasAgentAttribution) {
179
+ // ─── Single-persona path (vanilla Pi / OMP without active agent) ────────
180
+ const personaExistedBefore = stateManager.persona_getByName(PI_PERSONA_NAME) !== null;
181
+ const persona = ensurePiPersona(stateManager, eiInterface);
182
+ result.personaCreated = !personaExistedBefore;
183
+
184
+ const existingMsgs = stateManager.messages_get(persona.id);
185
+ const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
186
+ if (externalIds.length > 0) {
187
+ stateManager.messages_remove(persona.id, externalIds);
188
+ }
189
+
190
+ const cutoffIso = processedSessions[targetSession.id] ?? null;
191
+ const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
192
+ const toAnalyze: Message[] = [];
193
+
194
+ for (const msg of messages) {
195
+ const msgMs = new Date(msg.timestamp).getTime();
196
+ const isOld = cutoffMs !== null && msgMs < cutoffMs;
197
+ const eiMsg = isOld
198
+ ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
199
+ : convertToEiMessage(msg, targetSession.id, qualify);
200
+
201
+ stateManager.messages_append(persona.id, eiMsg);
202
+ result.messagesImported++;
203
+ if (!isOld) toAnalyze.push(eiMsg);
204
+ }
205
+
206
+ stateManager.messages_sort(persona.id);
207
+ eiInterface?.onMessageAdded?.(persona.id);
208
+
209
+ if (toAnalyze.length > 0 && !signal?.aborted) {
210
+ const allInState = stateManager.messages_get(persona.id);
211
+ const analyzeIds = new Set(toAnalyze.map((m) => m.id));
212
+ const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
213
+ const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
214
+
215
+ const context: ExtractionContext = {
216
+ personaId: persona.id,
217
+ channelDisplayName: persona.display_name,
218
+ messages_context: contextMsgs,
219
+ messages_analyze: toAnalyze,
220
+ sources: [`pi:${getMachineId()}:${targetSession.id}`],
221
+ };
222
+
223
+ queuePersonRewritePhase(stateManager);
224
+ queueTopicRewritePhase(stateManager);
225
+ queueAllScans(context, stateManager, {
226
+ extraction_model: human.settings?.pi?.extraction_model,
227
+ external_filter: "only",
228
+ });
229
+ result.extractionScansQueued += 4;
230
+ }
231
+ } else {
232
+ // ─── Multi-agent path (OMP sessions with agent attribution) ─────────────
233
+ const byPersonaId = new Map<string, { persona: NonNullable<ReturnType<typeof stateManager.persona_getByName>>; msgs: typeof messages; agentName: string }>();
234
+
235
+ for (const msg of messages) {
236
+ const agentName = msg.agent ?? PI_PERSONA_NAME;
237
+ let persona = stateManager.persona_getByName(agentName);
238
+ if (!persona) {
239
+ const { canonical } = resolveCanonicalAgent(agentName);
240
+ persona = stateManager.persona_getByName(canonical);
241
+ }
242
+ if (!persona) {
243
+ persona = await ensureAgentPersona(agentName, {
244
+ stateManager,
245
+ interface: eiInterface,
246
+ reader: undefined,
247
+ });
248
+ result.personasCreated.push(agentName);
249
+ }
250
+ const bucket = byPersonaId.get(persona.id);
251
+ if (bucket) {
252
+ bucket.msgs.push(msg);
253
+ } else {
254
+ byPersonaId.set(persona.id, { persona, msgs: [msg], agentName });
255
+ }
256
+ }
257
+
258
+ const cutoffIso = processedSessions[targetSession.id] ?? null;
259
+ const cutoffMs = cutoffIso ? new Date(cutoffIso).getTime() : null;
260
+ let anyPersonaHasChanges = false;
261
+
262
+ for (const [, { persona, msgs: agentMsgs }] of byPersonaId) {
263
+ const existingMsgs = stateManager.messages_get(persona.id);
264
+ const externalIds = existingMsgs.filter((m) => m.external === true).map((m) => m.id);
265
+ if (externalIds.length > 0) {
266
+ stateManager.messages_remove(persona.id, externalIds);
267
+ }
268
+
269
+ const toAnalyze: Message[] = [];
270
+ for (const msg of agentMsgs) {
271
+ const msgMs = new Date(msg.timestamp).getTime();
272
+ const isOld = cutoffMs !== null && msgMs < cutoffMs;
273
+ const eiMsg = isOld
274
+ ? convertToPreMarkedEiMessage(msg, targetSession.id, qualify)
275
+ : convertToEiMessage(msg, targetSession.id, qualify);
276
+
277
+ stateManager.messages_append(persona.id, eiMsg);
278
+ result.messagesImported++;
279
+ if (!isOld) toAnalyze.push(eiMsg);
280
+ }
281
+
282
+ stateManager.messages_sort(persona.id);
283
+ eiInterface?.onMessageAdded?.(persona.id);
284
+
285
+ if (toAnalyze.length > 0 && !signal?.aborted) {
286
+ const allInState = stateManager.messages_get(persona.id);
287
+ const analyzeIds = new Set(toAnalyze.map((m) => m.id));
288
+ const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
289
+ const contextMsgs = analyzeStartIndex > 0 ? allInState.slice(0, analyzeStartIndex) : [];
290
+
291
+ const context: ExtractionContext = {
292
+ personaId: persona.id,
293
+ channelDisplayName: persona.display_name,
294
+ messages_context: contextMsgs,
295
+ messages_analyze: toAnalyze,
296
+ sources: [`pi:${getMachineId()}:${targetSession.id}`],
297
+ };
298
+
299
+ anyPersonaHasChanges = true;
300
+ queueAllScans(context, stateManager, {
301
+ extraction_model: human.settings?.pi?.extraction_model,
302
+ external_filter: "only",
303
+ });
304
+ result.extractionScansQueued += 4;
305
+ }
306
+ }
307
+
308
+ if (anyPersonaHasChanges && !signal?.aborted) {
309
+ queuePersonRewritePhase(stateManager);
310
+ queueTopicRewritePhase(stateManager);
311
+ }
220
312
  }
221
313
 
222
314
  result.sessionsProcessed = 1;
@@ -199,6 +199,7 @@ export class PiReader implements IPiReader {
199
199
  role,
200
200
  content,
201
201
  timestamp: ts ?? new Date(0).toISOString(),
202
+ agent: entry.agent as string | undefined,
202
203
  });
203
204
  }
204
205
 
@@ -49,6 +49,8 @@ export interface PiMessageEntry {
49
49
  id: string;
50
50
  parentId?: string;
51
51
  timestamp: string;
52
+ /** Active agent definition name, present only for OMP sessions. Mirrors the `agent` field on OpenCode message rows. */
53
+ agent?: string;
52
54
  message: PiMessagePayload;
53
55
  [key: string]: unknown;
54
56
  }
@@ -112,6 +114,8 @@ export interface PiMessage {
112
114
  role: "user" | "assistant";
113
115
  content: string;
114
116
  timestamp: string;
117
+ /** Active agent definition name when this message was recorded; undefined for vanilla Pi or OMP without an active agent. */
118
+ agent?: string;
115
119
  }
116
120
 
117
121
  // ============================================================================
@@ -63,8 +63,10 @@ export const ALL_PROVIDER_NAMES: ReadonlyArray<string> = [
63
63
  // For example, Haiku's advertised context is 200k but real-world extraction quality degrades
64
64
  // above ~100k, so we cap it there. When adding new models, prefer conservative values based
65
65
  // on actual usage over marketing specs.
66
- export const KNOWN_MODEL_LIMITS: Readonly<Record<string, { token_limit?: number; max_output_tokens?: number }>> = {
66
+ export const KNOWN_MODEL_LIMITS: Readonly<Record<string, { token_limit?: number; max_output_tokens?: number; temperature_disabled?: boolean }>> = {
67
67
  // Anthropic — claude-opus-4.x
68
+ // Models from 4-8 onward always use extended thinking and reject the temperature parameter.
69
+ "claude-opus-4-8": { token_limit: 200000, max_output_tokens: 128000, temperature_disabled: true },
68
70
  "claude-opus-4-7": { token_limit: 200000, max_output_tokens: 128000 },
69
71
  "claude-opus-4-6": { token_limit: 200000, max_output_tokens: 128000 },
70
72
  "claude-opus-4-5-20251101": { token_limit: 200000, max_output_tokens: 64000 },
@@ -361,9 +363,9 @@ export function buildProviderAccounts(
361
363
  name: modelName,
362
364
  ...(limits?.token_limit !== undefined && { token_limit: limits.token_limit }),
363
365
  ...(limits?.max_output_tokens !== undefined && { max_output_tokens: limits.max_output_tokens }),
366
+ ...(limits?.temperature_disabled === true && { temperature_disabled: true }),
364
367
  };
365
368
  };
366
-
367
369
  const seenNames = new Set<string>();
368
370
  const models: ModelConfig[] = [];
369
371