ei-tui 1.6.8 → 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.8",
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
+ }
@@ -130,43 +130,45 @@ async function installCodexHooks(): Promise<void> {
130
130
  const scriptContent = `#!/usr/bin/env bun
131
131
  import { $ } from "bun";
132
132
 
133
- const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
134
- const raw = (input.prompt ?? "").replace(/<[^>]*>/g, "").trim();
135
- const searchArgs = ["-n", "8"];
136
-
137
- const sessionArgs = [];
138
- if (input.transcript_path) {
139
- sessionArgs.push("--transcript", input.transcript_path);
140
- }
141
- if (input.session_id) {
142
- sessionArgs.push("--session", input.session_id, "--hook-source", "codex");
143
- }
144
-
145
- const args = raw ? [...searchArgs, ...sessionArgs, raw] : ["--recent", ...searchArgs];
146
-
147
133
  async function runEi(commandArgs) {
148
134
  const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
149
135
  if (direct.trim()) return direct;
150
136
  return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
151
137
  }
152
138
 
153
- const output = await runEi(args);
154
- if (output.trim()) {
155
- const heading = [
156
- "## Ei Memory Context",
157
- "*(The user cannot see this block. It is injected automatically before their message.)*",
158
- "*(If you reference anything from it, briefly explain where it came from — e.g. \\"Ei shows you've been working on X\\" — so the user isn't confused by knowledge that appeared from nowhere.)*",
159
- "",
160
- "Ei is a personal knowledge base built from the user's coding sessions, Slack, documents, and conversations.",
161
- "The following memories MAY be relevant to your current task — use \`ei_search\` or \`ei_lookup\` for targeted queries.",
162
- ].join("\\n");
163
-
164
- process.stdout.write(JSON.stringify({
165
- hookSpecificOutput: {
166
- hookEventName: "UserPromptSubmit",
167
- additionalContext: \`\\n\${heading}\\n\${output.trim()}\\n\`,
168
- },
169
- }));
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
+ }
170
172
  }
171
173
  `;
172
174
 
@@ -279,7 +281,14 @@ async function installClaudeCodeHooks(): Promise<void> {
279
281
  const scriptContent = `#!/usr/bin/env bun
280
282
  import { $ } from "bun";
281
283
 
282
- 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 = \`
283
292
  ## Ei Memory Context
284
293
  *(The user cannot see this block. It is injected automatically before their message.)*
285
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.)*
@@ -288,25 +297,21 @@ Ei is a personal knowledge base built from the user's coding sessions, Slack, do
288
297
  The following items MAY be relevant to your current task — use \\\`ei_search\\\` or \\\`ei_lookup\\\` for targeted queries.
289
298
  \`;
290
299
 
291
- const input = await new Response(Bun.stdin.stream()).json().catch(() => ({}));
292
- 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();
293
302
 
294
- const sessionArgs = [];
295
- if (input.session_id && input.hook_source) {
296
- sessionArgs.push("--session", input.session_id, "--hook-source", input.hook_source);
297
- } else if (input.transcript_path) {
298
- sessionArgs.push("--transcript", input.transcript_path);
299
- }
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
+ }
300
309
 
301
- const args = raw ? ["-n", "5", ...sessionArgs, raw] : ["--recent", "-n", "5"];
310
+ const args = raw ? ["-n", "5", ...sessionArgs, raw] : ["--recent", "-n", "5"];
302
311
 
303
- async function runEi(commandArgs) {
304
- const direct = await $\`ei \${commandArgs}\`.quiet().text().catch(() => "");
305
- if (direct.trim()) return direct;
306
- return await $\`bunx ei-tui@latest \${commandArgs}\`.quiet().text().catch(() => "");
312
+ const output = await runEi(args);
313
+ if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
307
314
  }
308
- const output = await runEi(args);
309
- if (output.trim()) process.stdout.write(\`\\n\${heading}\\n\${output.trim()}\\n\`);
310
315
  `;
311
316
 
312
317
  await Bun.write(scriptPath, scriptContent);
@@ -558,7 +563,6 @@ async function installOmp(): Promise<void> {
558
563
  const home = process.env.HOME || "~";
559
564
 
560
565
  const extensionContent = `import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
561
- import { Type } from "typebox";
562
566
  import { $ } from "bun";
563
567
 
564
568
  const runEi = async (cmdArgs: string[]): Promise<string> => {
@@ -567,8 +571,46 @@ const runEi = async (cmdArgs: string[]): Promise<string> => {
567
571
  return $\`bunx ei-tui@latest \${cmdArgs}\`.quiet().text().catch(() => "");
568
572
  };
569
573
 
574
+ // WHO block deduplication: Promise identity reuse — resolving is synchronous on subsequent calls.
575
+ const personaBlockFetch = new Map<string, Promise<string | null>>();
576
+
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;
583
+ }
584
+ }
570
585
 
571
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.
572
614
  pi.on("before_agent_start", async (event, ctx) => {
573
615
  const entries = ctx.sessionManager.getEntries();
574
616
  const recentMsgs = entries
@@ -584,12 +626,8 @@ export default function eiIntegration(pi: ExtensionAPI) {
584
626
  .join("\\n");
585
627
 
586
628
  const prompt = event.prompt ?? "";
587
- const args = prompt
588
- ? ["-n", "5", "--", prompt]
589
- : ["--recent", "-n", "5"];
590
-
629
+ const args = prompt ? ["-n", "5", "--", prompt] : ["--recent", "-n", "5"];
591
630
  const output = await runEi(args).catch(() => "");
592
-
593
631
  if (!output.trim()) return undefined;
594
632
 
595
633
  const heading = [
@@ -610,22 +648,25 @@ export default function eiIntegration(pi: ExtensionAPI) {
610
648
  };
611
649
  });
612
650
 
651
+ // Tools use plain JSON Schema — no typebox import needed (not available in source mode).
613
652
  pi.registerTool({
614
653
  name: "ei_search",
615
654
  label: "Search Ei Memory",
616
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.",
617
656
  promptSnippet: "Search Ei's personal memory for relevant facts, topics, people, or quotes.",
618
- parameters: Type.Object({
619
- query: Type.String({ description: "Natural language search query" }),
620
- type: Type.Optional(Type.Union([
621
- Type.Literal("facts"),
622
- Type.Literal("topics"),
623
- Type.Literal("people"),
624
- Type.Literal("quotes"),
625
- Type.Literal("personas"),
626
- ], { description: "Filter to a specific data type. Omit for balanced results across all types." })),
627
- }),
628
- async execute(_id, params, _signal, _onUpdate, _ctx) {
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) {
629
670
  const args = params.type
630
671
  ? [params.type, "-n", "5", "--", params.query]
631
672
  : ["-n", "5", "--", params.query];
@@ -641,10 +682,14 @@ export default function eiIntegration(pi: ExtensionAPI) {
641
682
  name: "ei_lookup",
642
683
  label: "Lookup Ei Entity",
643
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.",
644
- parameters: Type.Object({
645
- id: Type.String({ description: "Entity ID from ei_search results" }),
646
- }),
647
- async execute(_id, params, _signal, _onUpdate, _ctx) {
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) {
648
693
  const output = await runEi(["--id", params.id]).catch(() => "");
649
694
  return {
650
695
  content: [{ type: "text" as const, text: output.trim() || "Not found" }],
@@ -675,8 +720,8 @@ async function installOpenCodePlugin(): Promise<void> {
675
720
  import { join } from "path"
676
721
  import { appendFileSync } from "fs"
677
722
 
678
- const sessionCache = new Map<string, string | null>()
679
- 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>>()
680
725
 
681
726
  const logPath = join(process.env.EI_DATA_PATH ?? join(process.env.HOME ?? "~", ".local", "share", "ei"), "ei-persona-plugin.log")
682
727
 
@@ -686,11 +731,7 @@ function log(msg: string) {
686
731
  } catch {}
687
732
  }
688
733
 
689
- type PersonaTrait = { name: string; description: string; strength: number }
690
- type PersonaTopic = { name: string; perspective: string; approach: string; exposure_current: number }
691
- type PersonaResult = { display_name: string; base_prompt?: string; traits?: PersonaTrait[]; topics?: PersonaTopic[] }
692
-
693
- // Pulls the agent name from the system prompt. Handles OMO's multiple formats:
734
+ // Pulls the agent name from the system prompt. Handles OMO/OMP formats:
694
735
  // You are "Sisyphus" - ... (quoted, dash)
695
736
  // You are "Sisyphus - Ultraworker" (quoted, dash in name)
696
737
  // You are Atlas - ... (unquoted, dash)
@@ -704,51 +745,26 @@ export function extractAgentName(systemPrompt: string): string | null {
704
745
  return null
705
746
  }
706
747
 
707
- // Queries Ei for persona candidates and validates by name containment —
708
- // tolerates OMO renaming agents without requiring a hardcoded alias map.
709
- export async function resolveEiPersona(rawName: string): Promise<PersonaResult | null> {
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> {
710
758
  try {
711
- const direct = await $\`ei personas -n 5 \${rawName}\`.quiet().text().catch(() => "")
712
- const out = direct.trim() ? direct : await $\`bunx ei-tui@latest personas -n 5 \${rawName}\`.text()
713
- const candidates = JSON.parse(out.trim()) as PersonaResult[]
714
- if (!Array.isArray(candidates) || candidates.length === 0) return null
715
- const rawLower = rawName.toLowerCase()
716
- const match = candidates.find((p) => {
717
- const nameLower = p.display_name.toLowerCase()
718
- return rawLower.includes(nameLower) || nameLower.includes(rawLower)
719
- })
720
- return match ?? null
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()
721
763
  } catch {
722
764
  return null
723
765
  }
724
766
  }
725
767
 
726
- function buildEiRelationshipBlock(persona: PersonaResult): string {
727
- const strongTraits = (persona.traits ?? [])
728
- .filter((t) => t.strength >= 0.7)
729
- .sort((a, b) => b.strength - a.strength)
730
- .map((t) => \`**\${t.name}** (\${Math.round(t.strength * 100)}%): \${t.description}\`)
731
- .join("\\n")
732
- const sortedTopics = [...(persona.topics ?? [])]
733
- .sort((a, b) => b.exposure_current - a.exposure_current)
734
- .map((t) => \`**\${t.name}**: \${t.perspective} — \${t.approach}\`)
735
- .join("\\n")
736
- return [
737
- "<!-- ei-relationship-injected -->",
738
- "<ei-relationship>",
739
- "## Ei: Relationship Context",
740
- "",
741
- persona.base_prompt ?? "",
742
- "",
743
- "### Working Style",
744
- strongTraits || "(no traits above threshold)",
745
- "",
746
- "### Shared Context",
747
- sortedTopics || "(no topics)",
748
- "</ei-relationship>",
749
- ].join("\\n")
750
- }
751
-
752
768
  export default async function EiPersonaPlugin() {
753
769
  return {
754
770
  name: "ei-persona",
@@ -760,26 +776,13 @@ export default async function EiPersonaPlugin() {
760
776
  const rawName = extractAgentName(output.system[0])
761
777
  if (!rawName) return
762
778
 
763
- const cacheKey = \`\${input.sessionID ?? "unknown"}:\${rawName}\`
764
-
765
- if (sessionCache.has(cacheKey)) {
766
- const cached = sessionCache.get(cacheKey) ?? null
767
- if (cached !== null && !output.system[0].includes("<!-- ei-relationship-injected -->"))
768
- output.system[0] = output.system[0] + "\\n\\n" + cached
769
- return
770
- }
771
-
772
- if (!sessionFetch.has(cacheKey)) {
773
- sessionFetch.set(cacheKey, (async () => {
774
- const persona = await resolveEiPersona(rawName)
775
- if (!persona) return null
776
- log(\`ei-persona: injecting \${persona.display_name}\`)
777
- return buildEiRelationshipBlock(persona)
778
- })())
779
+ // 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))
779
783
  }
780
784
 
781
- const block = await sessionFetch.get(cacheKey)!
782
- sessionCache.set(cacheKey, block)
785
+ const block = await personaFetch.get(rawName)!
783
786
  if (block !== null && !output.system[0].includes("<!-- ei-relationship-injected -->"))
784
787
  output.system[0] = output.system[0] + "\\n\\n" + block
785
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