ei-tui 0.7.1 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.7.1",
3
+ "version": "0.8.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,6 +20,7 @@ export async function execute(query: string, limit: number, options: { recent?:
20
20
  name: fact.name,
21
21
  description: fact.description,
22
22
  sentiment: fact.sentiment,
23
- validated: fact.validated,
23
+ validated_date: fact.validated_date,
24
+ sources: fact.sources,
24
25
  }));
25
26
  }
@@ -22,5 +22,6 @@ export async function execute(query: string, limit: number, options: { recent?:
22
22
  relationship: person.relationship,
23
23
  sentiment: person.sentiment,
24
24
  identifiers: person.identifiers ?? [],
25
+ sources: person.sources,
25
26
  }));
26
27
  }
@@ -21,5 +21,6 @@ export async function execute(query: string, limit: number, options: { recent?:
21
21
  description: topic.description,
22
22
  category: topic.category,
23
23
  sentiment: topic.sentiment,
24
+ sources: topic.sources,
24
25
  }));
25
26
  }
package/src/cli/mcp.ts CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
4
  import { retrieveBalanced, lookupById, loadLatestState, type BalancedResult } from "./retrieval.js";
5
- import type { StorageState } from "../core/types/index.js";
5
+ import type { StorageState } from "../core/types.js";
6
6
  import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./persona-filter.js";
7
7
 
8
8
  // Exported so tests can inject their own transport
@@ -1,4 +1,4 @@
1
- import type { StorageState } from "../core/types/index.js";
1
+ import type { StorageState } from "../core/types.js";
2
2
  import type { BalancedResult } from "./retrieval.js";
3
3
 
4
4
  export function resolvePersonaId(state: StorageState, name: string): string | null {
@@ -94,6 +94,8 @@ export interface FactResult {
94
94
  name: string;
95
95
  description: string;
96
96
  sentiment: number;
97
+ validated_date?: string;
98
+ sources?: string[];
97
99
  }
98
100
 
99
101
  export interface PersonResult {
@@ -172,6 +174,8 @@ function mapFact(fact: Fact): FactResult {
172
174
  name: fact.name,
173
175
  description: fact.description,
174
176
  sentiment: fact.sentiment,
177
+ validated_date: fact.validated_date,
178
+ sources: fact.sources,
175
179
  };
176
180
  }
177
181
 
package/src/cli.ts CHANGED
@@ -16,6 +16,13 @@ import { join } from "path";
16
16
  import { retrieveBalanced, lookupById, loadLatestState } from "./cli/retrieval";
17
17
  import type { StorageState } from "./core/types";
18
18
  import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./cli/persona-filter.js";
19
+ import pkg from "../package.json" assert { type: "json" };
20
+
21
+ const rawArgs = process.argv.slice(2);
22
+ if (rawArgs.includes("--version") || rawArgs.includes("-v")) {
23
+ process.stdout.write(`${pkg.version}\n`);
24
+ process.exit(0);
25
+ }
19
26
 
20
27
  const TYPE_ALIASES: Record<string, string> = {
21
28
  quote: "quotes",
@@ -52,6 +52,7 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
52
52
  roomId,
53
53
  messages_context,
54
54
  messages_analyze,
55
+ sources: response.request.data.sources as string[] | undefined,
55
56
  candidateName: response.request.data.candidateName as string,
56
57
  candidateDescription: response.request.data.candidateDescription as string,
57
58
  candidateCategory: response.request.data.candidateCategory as string,
@@ -99,6 +100,7 @@ export function handlePersonMatch(response: LLMResponse, state: StateManager): v
99
100
  roomId,
100
101
  messages_context,
101
102
  messages_analyze,
103
+ sources: response.request.data.sources as string[] | undefined,
102
104
  candidateName: response.request.data.candidateName as string,
103
105
  candidateDescription: response.request.data.candidateDescription as string,
104
106
  candidateRelationship: response.request.data.candidateRelationship as string,
@@ -54,7 +54,7 @@ function buildResolvedModel(account: ProviderAccount, model: ModelConfig): Resol
54
54
  const apiModelId = model.model_id ?? model.name;
55
55
  return {
56
56
  provider: account.name,
57
- model: apiModelId === "(default)" ? undefined : apiModelId,
57
+ model: apiModelId === "default" ? undefined : apiModelId,
58
58
  config: {
59
59
  name: account.name,
60
60
  baseURL: account.url,
@@ -77,6 +77,7 @@ export function resolveModelById(
77
77
  }
78
78
 
79
79
  export function getDisplayName(account: ProviderAccount, model: ModelConfig): string {
80
+ if (model.name === "default") return account.name;
80
81
  return `${account.name}:${model.name}`;
81
82
  }
82
83
 
@@ -119,7 +120,7 @@ export function resolveModel(modelSpec?: string, accounts?: ProviderAccount[]):
119
120
  (acc) => acc.name.toLowerCase() === searchName.toLowerCase() && acc.enabled && acc.type === "llm"
120
121
  );
121
122
  if (matchingAccount) {
122
- const matchingModel = matchingAccount.models?.find((m) => m.name === model);
123
+ const matchingModel = matchingAccount.models?.find((m) => m.name === model || m.model_id === model);
123
124
  if (matchingModel) {
124
125
  return buildResolvedModel(matchingAccount, matchingModel);
125
126
  }
@@ -162,7 +163,7 @@ function findModelAndAccount(
162
163
  const account = accounts.find(
163
164
  (a) => a.name.toLowerCase() === providerName.toLowerCase() && a.enabled
164
165
  );
165
- const model = account?.models?.find((m) => m.name === modelName);
166
+ const model = account?.models?.find((m) => m.name === modelName || m.model_id === modelName);
166
167
  return { model, account };
167
168
  }
168
169
  // Try matching by model UUID first
@@ -12,8 +12,10 @@ import {
12
12
  type TopicScanCandidate,
13
13
  type ItemMatchResult,
14
14
  type ParticipantContext,
15
+ type PersonaEntitySnapshot,
15
16
  } from "../../prompts/human/index.js";
16
17
  import { buildValidatePrompt } from "../../prompts/ceremony/dedup.js";
18
+ import { normalizeRoomMessages } from "../handlers/utils.js";
17
19
  import { chunkExtractionContext } from "./extraction-chunker.js";
18
20
  import { getEmbeddingService, findTopK, getTopicEmbeddingText } from "../embedding-service.js";
19
21
  import { resolveTokenLimit } from "../llm-client.js";
@@ -582,6 +584,18 @@ export function queuePersonUpdate(
582
584
 
583
585
  const primaryPersonaIdForUpdate = context.personaId.split("|")[0];
584
586
 
587
+ const linkedPersonaId = !isNewItem
588
+ ? (existingItem?.identifiers ?? []).find(i => i.type.toLowerCase() === 'ei persona')?.value
589
+ : undefined;
590
+ const linkedPersonaEntity = linkedPersonaId ? state.persona_getById(linkedPersonaId) : undefined;
591
+ const personaEntitySnapshot: PersonaEntitySnapshot | undefined = linkedPersonaEntity
592
+ ? {
593
+ long_description: linkedPersonaEntity.long_description ?? '',
594
+ traits: (linkedPersonaEntity.traits ?? []).map(t => ({ name: t.name, description: t.description })),
595
+ topics: (linkedPersonaEntity.topics ?? []).map(t => ({ name: t.name, perspective: t.perspective })),
596
+ }
597
+ : undefined;
598
+
585
599
  for (const chunk of chunks) {
586
600
  const prompt = buildPersonUpdatePrompt({
587
601
  existing_item: existingItem,
@@ -593,6 +607,7 @@ export function queuePersonUpdate(
593
607
  persona_name: chunk.personaDisplayName,
594
608
  participant_context: buildParticipantContext(primaryPersonaIdForUpdate, state),
595
609
  known_identifier_types: userIdentifierTypes,
610
+ persona_entity: personaEntitySnapshot,
596
611
  });
597
612
 
598
613
  state.queue_enqueue({
@@ -622,6 +637,116 @@ export function queuePersonUpdate(
622
637
  return chunks.length;
623
638
  }
624
639
 
640
+ export function queueTargetedPersonUpdate(
641
+ personId: string,
642
+ personaId: string,
643
+ state: StateManager,
644
+ roomId?: string
645
+ ): number {
646
+ const existingItem = state.getHuman().people.find(p => p.id === personId) ?? null;
647
+ if (!existingItem) {
648
+ console.warn(`[queueTargetedPersonUpdate] Person ${personId} not found`);
649
+ return 0;
650
+ }
651
+
652
+ let allMessages: Message[];
653
+ let contextPersonaId: string;
654
+ let displayName: string;
655
+
656
+ if (roomId) {
657
+ const room = state.getRoom(roomId);
658
+ if (!room) {
659
+ console.warn(`[queueTargetedPersonUpdate] Room ${roomId} not found`);
660
+ return 0;
661
+ }
662
+ allMessages = normalizeRoomMessages(state.getRoomActivePath(roomId), state);
663
+ contextPersonaId = room.persona_ids.join("|");
664
+ displayName = room.display_name;
665
+ } else {
666
+ const persona = state.persona_getById(personaId);
667
+ if (!persona) {
668
+ console.warn(`[queueTargetedPersonUpdate] Persona ${personaId} not found`);
669
+ return 0;
670
+ }
671
+ allMessages = state.messages_get(personaId);
672
+ contextPersonaId = personaId;
673
+ displayName = persona.display_name;
674
+ }
675
+
676
+ if (allMessages.length === 0) return 0;
677
+
678
+ const model = state.getHuman().settings?.default_model;
679
+ const context: ExtractionContext & {
680
+ candidateName: string;
681
+ candidateDescription: string;
682
+ candidateRelationship: string;
683
+ extraction_model?: string;
684
+ } = {
685
+ personaId: contextPersonaId,
686
+ personaDisplayName: displayName,
687
+ messages_context: [],
688
+ messages_analyze: allMessages,
689
+ candidateName: existingItem.name,
690
+ candidateDescription: existingItem.description,
691
+ candidateRelationship: existingItem.relationship,
692
+ extraction_model: model,
693
+ roomId,
694
+ };
695
+
696
+ const matchResult: ItemMatchResult = { matched_guid: personId };
697
+ return queuePersonUpdate(matchResult, context, state, existingItem);
698
+ }
699
+
700
+ export function queueTargetedTopicUpdate(
701
+ topicId: string,
702
+ personaId: string,
703
+ state: StateManager,
704
+ roomId?: string
705
+ ): number {
706
+ const existingItem = state.getHuman().topics.find(t => t.id === topicId) ?? null;
707
+ if (!existingItem) {
708
+ console.warn(`[queueTargetedTopicUpdate] Topic ${topicId} not found`);
709
+ return 0;
710
+ }
711
+
712
+ let allMessages: Message[];
713
+ let contextPersonaId: string;
714
+ let displayName: string;
715
+
716
+ if (roomId) {
717
+ const room = state.getRoom(roomId);
718
+ if (!room) {
719
+ console.warn(`[queueTargetedTopicUpdate] Room ${roomId} not found`);
720
+ return 0;
721
+ }
722
+ allMessages = normalizeRoomMessages(state.getRoomActivePath(roomId), state);
723
+ contextPersonaId = room.persona_ids.join("|");
724
+ displayName = room.display_name;
725
+ } else {
726
+ const persona = state.persona_getById(personaId);
727
+ if (!persona) {
728
+ console.warn(`[queueTargetedTopicUpdate] Persona ${personaId} not found`);
729
+ return 0;
730
+ }
731
+ allMessages = state.messages_get(personaId);
732
+ contextPersonaId = personaId;
733
+ displayName = persona.display_name;
734
+ }
735
+
736
+ if (allMessages.length === 0) return 0;
737
+
738
+ const model = state.getHuman().settings?.default_model;
739
+ const context: ExtractionContext = {
740
+ personaId: contextPersonaId,
741
+ personaDisplayName: displayName,
742
+ messages_context: [],
743
+ messages_analyze: allMessages,
744
+ roomId,
745
+ };
746
+
747
+ return queueDirectTopicUpdate(existingItem, context, state, { extraction_model: model });
748
+ }
749
+
625
750
  export async function queueTopicValidate(
626
751
  newTopic: Topic,
627
752
  state: StateManager,
@@ -9,6 +9,8 @@ export {
9
9
  queuePersonUpdate,
10
10
  queueEventSummary,
11
11
  queueTopicValidate,
12
+ queueTargetedPersonUpdate,
13
+ queueTargetedTopicUpdate,
12
14
  VALIDATE_MIN_SIMILARITY,
13
15
  type ExtractionContext,
14
16
  type ExtractionOptions,
@@ -58,7 +58,7 @@ function buildRoomParticipantContext(roomId: string, state: StateManager): Parti
58
58
  };
59
59
  }
60
60
 
61
- function getRoomVisibleMessages(state: StateManager, roomId: string): Message[] {
61
+ export function getRoomVisibleMessages(state: StateManager, roomId: string): Message[] {
62
62
  const room = state.getRoom(roomId);
63
63
  if (!room) return [];
64
64
  const rawMessages = room.mode === RoomMode.FreeForAll
@@ -26,6 +26,7 @@ export function resolveCanonicalAgent(agentName: string): { canonical: string; a
26
26
  let name = agentName;
27
27
  name = name.replace(/^ai-sdlc[:-]/, "");
28
28
  name = name.replace(/\s*\([^)]+\)\s*$/, "").trim();
29
+ name = name.replace(/\s{2,}\S+.*$/, "").trim();
29
30
  name = name.replace(/-/g, " ");
30
31
  const canonical = name.replace(/\b\w/g, (c) => c.toUpperCase());
31
32
 
@@ -39,7 +39,7 @@ import { ContextStatus as ContextStatusEnum, RoomMode } from "./types.js";
39
39
  import { registerReadMemoryExecutor, registerFileReadExecutor } from "./tools/index.js";
40
40
  import { createReadMemoryExecutor } from "./tools/builtin/read-memory.js";
41
41
  import { EI_WELCOME_MESSAGE, EI_PERSONA_DEFINITION } from "../templates/welcome.js";
42
- import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueUserDedupRequest, queueRoomCapture, queuePersonaCapture, checkAndQueueRoomExtraction } from "./orchestrators/index.js";
42
+ import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueUserDedupRequest, queueRoomCapture, queuePersonaCapture, checkAndQueueRoomExtraction, queueTargetedPersonUpdate, queueTargetedTopicUpdate } from "./orchestrators/index.js";
43
43
  import { BUILT_IN_FACTS } from "./constants/built-in-facts.js";
44
44
  import { DEFAULT_SEED_TRAITS } from "./constants/seed-traits.js";
45
45
 
@@ -1994,6 +1994,14 @@ const toolNextSteps = new Set([
1994
1994
  queueRoomCapture(this.stateManager, roomId);
1995
1995
  }
1996
1996
 
1997
+ captureTargetedPerson(personId: string, personaId: string, roomId?: string): number {
1998
+ return queueTargetedPersonUpdate(personId, personaId, this.stateManager, roomId);
1999
+ }
2000
+
2001
+ captureTargetedTopic(topicId: string, personaId: string, roomId?: string): number {
2002
+ return queueTargetedTopicUpdate(topicId, personaId, this.stateManager, roomId);
2003
+ }
2004
+
1997
2005
  async submitOneShot(guid: string, systemPrompt: string, userPrompt: string): Promise<void> {
1998
2006
  return submitOneShot(
1999
2007
  this.stateManager,
@@ -373,11 +373,18 @@ export class StateManager {
373
373
  }
374
374
  }
375
375
 
376
+ for (const m of account.models) {
377
+ if (m.name === "(default)") {
378
+ m.name = "default";
379
+ if (m.model_id === "(default)") m.model_id = undefined;
380
+ }
381
+ }
382
+
376
383
  // If still no models, create a placeholder
377
384
  if (account.models.length === 0) {
378
- const model = { id: crypto.randomUUID(), name: "(default)" };
385
+ const model = { id: crypto.randomUUID(), name: "default" };
379
386
  account.models.push(model);
380
- modelLookup.set(`${account.name}:(default)`, model.id);
387
+ modelLookup.set(`${account.name}:default`, model.id);
381
388
  account.default_model = model.id;
382
389
  }
383
390
 
@@ -816,13 +823,6 @@ export class StateManager {
816
823
 
817
824
  messages_remove(personaId: string, messageIds: string[]): Message[] {
818
825
  const result = this.personaState.messages_remove(personaId, messageIds);
819
- const removedIds = new Set(result.map(m => m.id));
820
- const quotes = this.humanState.get().quotes ?? [];
821
- for (const quote of quotes) {
822
- if (quote.message_id && removedIds.has(quote.message_id)) {
823
- quote.message_id = null;
824
- }
825
- }
826
826
  this.scheduleSave();
827
827
  return result;
828
828
  }
@@ -12,6 +12,7 @@ import {
12
12
  type ExtractionContext,
13
13
  } from "../../core/orchestrators/human-extraction.js";
14
14
  import { isProcessRunning } from "../process-check.js";
15
+ import { getMachineId } from "../machine-id.js";
15
16
 
16
17
  // =============================================================================
17
18
  // Export Types
@@ -268,7 +269,7 @@ export async function importClaudeCodeSessions(
268
269
  personaDisplayName: persona.display_name,
269
270
  messages_context: contextMsgs,
270
271
  messages_analyze: toAnalyze,
271
- sources: [`claudecode:${targetSession.id}`],
272
+ sources: [`claudecode:${getMachineId()}:${targetSession.id}`],
272
273
  };
273
274
 
274
275
  const ccSettings = stateManager.getHuman().settings?.claudeCode;
@@ -8,6 +8,7 @@ import {
8
8
  } from "./types.js";
9
9
  import { CursorReader } from "./reader.js";
10
10
  import { isProcessRunning } from "../process-check.js";
11
+ import { getMachineId } from "../machine-id.js";
11
12
  import {
12
13
  queueAllScans,
13
14
  type ExtractionContext,
@@ -227,7 +228,7 @@ export async function importCursorSessions(
227
228
  personaDisplayName: persona.display_name,
228
229
  messages_context: contextMsgs,
229
230
  messages_analyze: toAnalyze,
230
- sources: [`cursor:${targetSession.id}`],
231
+ sources: [`cursor:${getMachineId()}:${targetSession.id}`],
231
232
  };
232
233
 
233
234
  queueAllScans(context, stateManager, { external_filter: "only" });
@@ -0,0 +1,5 @@
1
+ import { hostname } from "node:os";
2
+
3
+ export function getMachineId(): string {
4
+ return hostname().split(".")[0].toLowerCase();
5
+ }
@@ -9,6 +9,7 @@ import {
9
9
  type ExtractionContext,
10
10
  } from "../../core/orchestrators/human-extraction.js";
11
11
  import { isProcessRunning } from "../process-check.js";
12
+ import { getMachineId } from "../machine-id.js";
12
13
 
13
14
  // =============================================================================
14
15
  // Constants
@@ -250,7 +251,7 @@ export async function importOpenCodeSessions(
250
251
  personaDisplayName: persona.display_name,
251
252
  messages_context: contextMsgs,
252
253
  messages_analyze: toAnalyze,
253
- sources: [`opencode:${targetSession.id}`],
254
+ sources: [`opencode:${getMachineId()}:${targetSession.id}`],
254
255
  };
255
256
 
256
257
  if (!signal?.aborted) {
@@ -180,6 +180,8 @@ export const AGENT_ALIASES: Record<string, string[]> = {
180
180
  "sisyphus",
181
181
  "Sisyphus",
182
182
  "Sisyphus (Ultraworker)",
183
+ "Sisyphus Ultraworker",
184
+ "sisyphus ultraworker",
183
185
  "Planner-Sisyphus",
184
186
  "planner-sisyphus",
185
187
  ],
@@ -15,6 +15,7 @@ export type { PersonUpdatePromptData } from "./person-update.js";
15
15
  export type {
16
16
  PromptOutput,
17
17
  ParticipantContext,
18
+ PersonaEntitySnapshot,
18
19
  FactScanPromptData,
19
20
  TopicScanPromptData,
20
21
  PersonScanPromptData,
@@ -1,4 +1,4 @@
1
- import type { PromptOutput, ParticipantContext } from "./types.js";
1
+ import type { PromptOutput, ParticipantContext, PersonaEntitySnapshot } from "./types.js";
2
2
  import type { Person, Message } from "../../core/types.js";
3
3
  import { formatMessagesAsPlaceholders } from "../message-utils.js";
4
4
 
@@ -12,6 +12,7 @@ export interface PersonUpdatePromptData {
12
12
  persona_name: string;
13
13
  participant_context?: ParticipantContext;
14
14
  known_identifier_types?: string[];
15
+ persona_entity?: PersonaEntitySnapshot;
15
16
  }
16
17
 
17
18
  function participantContextSection(ctx: ParticipantContext | undefined): string {
@@ -46,6 +47,9 @@ export function buildPersonUpdatePrompt(data: PersonUpdatePromptData): PromptOut
46
47
  const personaName = data.persona_name;
47
48
  const isNewItem = data.existing_item === null;
48
49
  const humanName = data.participant_context?.human_name;
50
+ const isEiPersona = !isNewItem && (data.existing_item?.identifiers ?? []).some(
51
+ i => i.type.toLowerCase() === 'ei persona'
52
+ );
49
53
 
50
54
  const builtInTypes = ['Full Name', 'First Name', 'Nickname', 'Email', 'GitHub', 'Discord',
51
55
  'Roblox', 'Reddit', 'Twitter', 'FF14', 'Relationship', 'Ei Persona'];
@@ -99,19 +103,61 @@ Detail you add should:
99
103
  2. **NOT** already be present in the record above`;
100
104
 
101
105
  // ── DESCRIPTION SECTION ───────────────────────────────────────────────────
102
- // New: shorter, no "don't accumulate" guidance (nothing to accumulate yet)
103
- // Existing: full synthesis guidance
106
+ // Three modes:
107
+ // isNewItem → brief, factual bootstrap (1-3 sentences)
108
+ // isEiPersona → living identity log (accumulate, never truncate)
109
+ // existing regular → synthesize to current-state (3-4 sentences)
104
110
 
105
- const descriptionSection = isNewItem
106
- ? `A concise summary of who this person is and how they relate to the HUMAN USER. Keep it brief and factual — only what you can confirm from the conversation.
111
+ let descriptionSection: string;
112
+
113
+ if (isNewItem) {
114
+ descriptionSection = `A concise summary of who this person is and how they relate to the HUMAN USER. Keep it brief and factual — only what you can confirm from the conversation.
107
115
 
108
116
  - Capture who this person IS — their role in the user's life
109
117
  - Be useful to a persona who's never heard this person's name before
110
118
  - 1-3 sentences maximum
111
119
  - If you know their birth date or birth year, include it as a date (e.g. "born 1986-10-28") — never as a current age (ages change, dates don't)
112
120
 
113
- **ABSOLUTELY VITAL**: Do **NOT** embellish. Record only what the user actually said or demonstrated.`
114
- : `A concise summary of who this person is and how they relate to the HUMAN USER. Personas use this to recognize this person and engage meaningfully when they come up.
121
+ **ABSOLUTELY VITAL**: Do **NOT** embellish. Record only what the user actually said or demonstrated.`;
122
+
123
+ } else if (isEiPersona) {
124
+ const entityRef = data.persona_entity
125
+ ? `## This Persona's Defined Identity (for reference)
126
+
127
+ The following is ${personName}'s current self-definition — their traits, topics, and long description as set in the Persona editor. Use this as a **baseline**, not a ceiling.
128
+
129
+ **Long description:**
130
+ ${data.persona_entity.long_description}
131
+
132
+ **Traits:**
133
+ ${data.persona_entity.traits.map(t => `- **${t.name}**: ${t.description}`).join('\n')}
134
+
135
+ **Topics:**
136
+ ${data.persona_entity.topics.map(t => `- **${t.name}**: ${t.perspective}`).join('\n')}
137
+
138
+ `
139
+ : '';
140
+
141
+ descriptionSection = `${entityRef}This record is the HUMAN USER's **observed experience** of ${personName} over time — not the Persona's own definition. Think of it as field notes from someone who has been talking with this Persona across many conversations.
142
+
143
+ ## Your job: add, never truncate
144
+
145
+ The description is allowed to grow. **Never remove or summarize away existing content.**
146
+
147
+ Add anything from the Most Recent Messages that:
148
+ - Extends or nuances what's already known (new behaviors, new opinions, recurring themes)
149
+ - Agrees with or contradicts the Persona's defined identity — both are worth capturing
150
+ - Reveals how the HUMAN USER experiences or relates to this Persona specifically
151
+
152
+ **Do NOT:**
153
+ - Synthesize the existing description down to fewer sentences
154
+ - Replace specific observations with vague summaries
155
+ - Discard detail to "keep it brief" — brevity is wrong here
156
+
157
+ If the new messages add nothing meaningful, return \`{}\`. Otherwise, return the **full updated description** — existing content preserved, new observations woven in.`;
158
+
159
+ } else {
160
+ descriptionSection = `A concise summary of who this person is and how they relate to the HUMAN USER. Personas use this to recognize this person and engage meaningfully when they come up.
115
161
 
116
162
  ## CRITICAL: Synthesize, don't accumulate
117
163
 
@@ -134,6 +180,7 @@ The description should NOT:
134
180
  - Record someone's age — record their birth date or birth year instead (e.g. "born 1981" not "age 44")
135
181
 
136
182
  **ABSOLUTELY VITAL**: Do **NOT** embellish — personas use their own voice. Record what the user actually said or demonstrated, not your interpretation of its emotional significance.`;
183
+ }
137
184
 
138
185
  // ── IDENTIFIERS ───────────────────────────────────────────────────────────
139
186
 
@@ -12,6 +12,13 @@ export interface ParticipantContext {
12
12
  human_age?: number; // calculated from Birthday fact — omitted if not set
13
13
  }
14
14
 
15
+ /** Snapshot of a linked Persona entity — passed to person-update when the Person record has an 'Ei Persona' identifier. */
16
+ export interface PersonaEntitySnapshot {
17
+ long_description: string;
18
+ traits: Array<{ name: string; description: string }>;
19
+ topics: Array<{ name: string; perspective: string }>;
20
+ }
21
+
15
22
  interface BaseScanPromptData {
16
23
  messages_context: Message[];
17
24
  messages_analyze: Message[];
@@ -368,7 +368,6 @@ Rooms are shared multi-persona conversations — a space where the Human and mul
368
368
  ## Learning About the Human
369
369
  As the human chats, the system learns about them:
370
370
  - **Facts**: Objective information (job, location, family members)
371
- - **Traits**: Personality characteristics and tendencies
372
371
  - **Topics**: Interests and how they feel about them
373
372
  - **People**: Relationships in their life
374
373
  - **Quotes**: Memorable things said in conversation (human selects these with ${viewQuotesAction})
package/tui/README.md CHANGED
@@ -105,8 +105,8 @@ Rooms have three modes, set at creation time:
105
105
 
106
106
  | Command | Aliases | Description |
107
107
  |---------|---------|-------------|
108
- | `/me` | | Edit all your data (facts, traits, topics, people) in `$EDITOR` |
109
- | `/me <type>` | | Edit one type: `facts`, `traits`, `topics`, or `people` |
108
+ | `/me` | | Edit all your data (facts, topics, people) in `$EDITOR` |
109
+ | `/me <type>` | | Edit one type: `facts`, `topics`, or `people` |
110
110
  | `/dedupe <person\|topic> <term> [term2 ...]` | | Fuzzy-search and merge duplicate people or topics in `$EDITOR`. Unquoted words are individual OR terms; quoted strings match as exact phrases: `/dedupe person Flare "Jeremy Scherer"` finds records matching `Flare` OR `Jeremy Scherer` |
111
111
  | `/settings` | `/set` | Edit your global settings in `$EDITOR` |
112
112
  | `/setsync <user> <pass>` | `/ss` | Set sync credentials (triggers restart) |
@@ -1,10 +1,11 @@
1
+ import { PersonPickerOverlay } from "../components/PersonPickerOverlay.js";
1
2
  import type { Command } from "./registry";
2
3
 
3
4
  export const captureCommand: Command = {
4
5
  name: "capture",
5
6
  aliases: [],
6
7
  description: "Trigger extraction on current chat",
7
- usage: "/capture | /capture opencode",
8
+ usage: "/capture | /capture opencode | /capture person <name> | /capture topic <name>",
8
9
 
9
10
  async execute(args, ctx) {
10
11
  if (args.length === 0) {
@@ -18,12 +19,110 @@ export const captureCommand: Command = {
18
19
  return;
19
20
  }
20
21
 
22
+ const subcommand = args[0].toLowerCase();
23
+
24
+ if (subcommand === "person" || subcommand === "people") {
25
+ const searchTerm = args.slice(1).join(" ").trim();
26
+ if (!searchTerm) {
27
+ ctx.showNotification("Usage: /capture person <name>", "error");
28
+ return;
29
+ }
30
+ const human = await ctx.ei.getHuman();
31
+ const matches = (human.people ?? []).filter(p =>
32
+ p.name.toLowerCase().includes(searchTerm.toLowerCase())
33
+ );
34
+ if (matches.length === 0) {
35
+ ctx.showNotification(`No person matching "${searchTerm}" found`, "warn");
36
+ return;
37
+ }
38
+
39
+ const firePerson = (id: string, name: string) => {
40
+ const queued = ctx.ei.captureTargetedPerson(id);
41
+ if (queued === 0) {
42
+ ctx.showNotification(`No messages to scan for "${name}"`, "warn");
43
+ } else {
44
+ ctx.showNotification(`Re-scan queued for "${name}" (${queued} chunk${queued !== 1 ? "s" : ""})`, "info");
45
+ }
46
+ };
47
+
48
+ if (matches.length === 1) {
49
+ firePerson(matches[0].id, matches[0].name);
50
+ return;
51
+ }
52
+
53
+ ctx.showOverlay((hideOverlay) => (
54
+ <PersonPickerOverlay
55
+ title={`Multiple matches for "${searchTerm}" — pick one:`}
56
+ people={matches.map(p => ({
57
+ id: p.id,
58
+ name: p.name,
59
+ relationship: p.relationship,
60
+ description: p.description,
61
+ }))}
62
+ onSelect={(picked) => {
63
+ hideOverlay();
64
+ firePerson(picked.id, picked.name);
65
+ }}
66
+ onDismiss={hideOverlay}
67
+ />
68
+ ), ctx.renderer);
69
+ return;
70
+ }
71
+
72
+ if (subcommand === "topic") {
73
+ const searchTerm = args.slice(1).join(" ").trim();
74
+ if (!searchTerm) {
75
+ ctx.showNotification("Usage: /capture topic <name>", "error");
76
+ return;
77
+ }
78
+ const human = await ctx.ei.getHuman();
79
+ const matches = (human.topics ?? []).filter(t =>
80
+ t.name.toLowerCase().includes(searchTerm.toLowerCase())
81
+ );
82
+ if (matches.length === 0) {
83
+ ctx.showNotification(`No topic matching "${searchTerm}" found`, "warn");
84
+ return;
85
+ }
86
+
87
+ const fireTopic = (id: string, name: string) => {
88
+ const queued = ctx.ei.captureTargetedTopic(id);
89
+ if (queued === 0) {
90
+ ctx.showNotification(`No messages to scan for "${name}"`, "warn");
91
+ } else {
92
+ ctx.showNotification(`Re-scan queued for "${name}" (${queued} chunk${queued !== 1 ? "s" : ""})`, "info");
93
+ }
94
+ };
95
+
96
+ if (matches.length === 1) {
97
+ fireTopic(matches[0].id, matches[0].name);
98
+ return;
99
+ }
100
+
101
+ ctx.showOverlay((hideOverlay) => (
102
+ <PersonPickerOverlay
103
+ title={`Multiple matches for "${searchTerm}" — pick one:`}
104
+ people={matches.map(t => ({
105
+ id: t.id,
106
+ name: t.name,
107
+ relationship: t.category,
108
+ description: t.description,
109
+ }))}
110
+ onSelect={(picked) => {
111
+ hideOverlay();
112
+ fireTopic(picked.id, picked.name);
113
+ }}
114
+ onDismiss={hideOverlay}
115
+ />
116
+ ), ctx.renderer);
117
+ return;
118
+ }
119
+
21
120
  const integrationMap: Record<string, "opencode" | "claudeCode" | "cursor"> = {
22
121
  opencode: "opencode",
23
122
  claudecode: "claudeCode",
24
123
  cursor: "cursor",
25
124
  };
26
- const integrationKey = integrationMap[args[0].toLowerCase()];
125
+ const integrationKey = integrationMap[subcommand];
27
126
  if (integrationKey) {
28
127
  const human = await ctx.ei.getHuman();
29
128
  const intSettings = human.settings?.[integrationKey];
@@ -31,7 +130,6 @@ export const captureCommand: Command = {
31
130
  ctx.showNotification(`${args[0]} integration not enabled. Enable in /settings.`, "warn");
32
131
  return;
33
132
  }
34
- // Reset last_sync to epoch to force immediate scan on next processor tick
35
133
  await ctx.ei.updateHuman({
36
134
  settings: {
37
135
  ...human.settings,
@@ -45,6 +143,6 @@ export const captureCommand: Command = {
45
143
  return;
46
144
  }
47
145
 
48
- ctx.showNotification("Named capture not yet supported. Use /room or /persona to switch first.", "warn");
146
+ ctx.showNotification("Usage: /capture | /capture person <name> | /capture topic <name> | /capture opencode|claudecode|cursor", "warn");
49
147
  },
50
148
  };
@@ -54,7 +54,7 @@ export function WelcomeOverlay(props: WelcomeOverlayProps) {
54
54
  Options:
55
55
  </text>
56
56
  <text fg="#93a1a1">
57
- 1. Start a local LLM (LM Studio, Ollama) on port 1234
57
+ 1. Start LMStudio (port 1234) or Ollama (port 11434)
58
58
  </text>
59
59
  <text fg="#93a1a1">
60
60
  2. Run /provider new to configure a cloud provider
@@ -139,6 +139,8 @@ export interface EiContextValue {
139
139
  markAllRoomMessagesRead: () => Promise<number>;
140
140
  captureRoom: () => void;
141
141
  capturePersona: () => void;
142
+ captureTargetedPerson: (personId: string) => number;
143
+ captureTargetedTopic: (topicId: string) => number;
142
144
  sendSilenceMessage: (silenceReason?: string) => Promise<void>;
143
145
  humanRoomMessagePending: () => boolean;
144
146
  getArchivedRooms: () => RoomSummary[];
@@ -655,6 +657,24 @@ export const EiProvider: ParentComponent = (props) => {
655
657
  processor.capturePersona(personaId);
656
658
  };
657
659
 
660
+ const captureTargetedPerson = (personId: string): number => {
661
+ if (!processor) return 0;
662
+ const roomId = store.activeRoomId;
663
+ if (roomId) return processor.captureTargetedPerson(personId, '', roomId);
664
+ const personaId = store.activePersonaId;
665
+ if (!personaId) return 0;
666
+ return processor.captureTargetedPerson(personId, personaId);
667
+ };
668
+
669
+ const captureTargetedTopic = (topicId: string): number => {
670
+ if (!processor) return 0;
671
+ const roomId = store.activeRoomId;
672
+ if (roomId) return processor.captureTargetedTopic(topicId, '', roomId);
673
+ const personaId = store.activePersonaId;
674
+ if (!personaId) return 0;
675
+ return processor.captureTargetedTopic(topicId, personaId);
676
+ };
677
+
658
678
  const resolveRoomName = (nameOrAlias: string): string | null => {
659
679
  if (!processor) return null;
660
680
  return processor.resolveRoomName(nameOrAlias);
@@ -721,41 +741,57 @@ export const EiProvider: ParentComponent = (props) => {
721
741
  logger.info("E2E_SKIP_LOCAL_DETECT active, skipping local LLM check");
722
742
  setShowWelcomeOverlay(true);
723
743
  } else if (!hasAccounts) {
724
- logger.info("No LLM accounts configured, checking for local LLM...");
725
- try {
726
- const response = await fetch("http://127.0.0.1:1234/v1/models", {
727
- method: "GET",
728
- signal: AbortSignal.timeout(3000),
729
- });
730
- if (response.ok) {
731
- logger.info("Local LLM detected, auto-configuring...");
744
+ logger.info("No LLM accounts configured, checking for local LLMs...");
745
+
746
+ const candidates = [
747
+ { name: "LMStudio", url: "http://127.0.0.1:1234/v1" },
748
+ { name: "Ollama", url: "http://127.0.0.1:11434/v1" },
749
+ ];
750
+
751
+ const detected = await Promise.all(
752
+ candidates.map(async (candidate) => {
753
+ try {
754
+ const response = await fetch(`${candidate.url}/models`, {
755
+ method: "GET",
756
+ signal: AbortSignal.timeout(3000),
757
+ });
758
+ return response.ok ? candidate : null;
759
+ } catch {
760
+ return null;
761
+ }
762
+ })
763
+ );
764
+
765
+ const found = detected.filter(Boolean) as typeof candidates;
766
+
767
+ if (found.length > 0) {
768
+ const accounts: ProviderAccount[] = found.map((candidate) => {
732
769
  const defaultModelId = crypto.randomUUID();
733
- const localAccount: ProviderAccount = {
770
+ return {
734
771
  id: crypto.randomUUID(),
735
- name: "Local LLM",
772
+ name: candidate.name,
736
773
  type: "llm" as ProviderType,
737
- url: "http://127.0.0.1:1234/v1",
774
+ url: candidate.url,
738
775
  enabled: true,
739
776
  created_at: new Date().toISOString(),
740
777
  default_model: defaultModelId,
741
- models: [{ id: defaultModelId, name: "(default)" }],
778
+ models: [{ id: defaultModelId, name: "default" }],
742
779
  };
743
- const currentHuman = await processor!.getHuman();
744
- await processor!.updateHuman({
745
- settings: {
746
- ...currentHuman.settings,
747
- accounts: [localAccount],
748
- default_model: defaultModelId,
749
- },
750
- });
751
- showNotification("Local LLM detected and configured!", "info");
752
- logger.info("Local LLM auto-configured successfully");
753
- } else {
754
- logger.info("Local LLM check failed, showing welcome overlay");
755
- setShowWelcomeOverlay(true);
756
- }
757
- } catch {
758
- logger.info("No local LLM found, showing welcome overlay");
780
+ });
781
+ const firstDefaultModelId = accounts[0].default_model!;
782
+ const currentHuman = await processor!.getHuman();
783
+ await processor!.updateHuman({
784
+ settings: {
785
+ ...currentHuman.settings,
786
+ accounts,
787
+ default_model: firstDefaultModelId,
788
+ },
789
+ });
790
+ const names = found.map((c) => c.name).join(" and ");
791
+ showNotification(`${names} detected and configured!`, "info");
792
+ logger.info(`Auto-configured: ${names}`);
793
+ } else {
794
+ logger.info("No local LLMs found, showing welcome overlay");
759
795
  setShowWelcomeOverlay(true);
760
796
  }
761
797
  }
@@ -952,6 +988,8 @@ export const EiProvider: ParentComponent = (props) => {
952
988
  markAllRoomMessagesRead,
953
989
  captureRoom,
954
990
  capturePersona,
991
+ captureTargetedPerson,
992
+ captureTargetedTopic,
955
993
  sendSilenceMessage,
956
994
  humanRoomMessagePending,
957
995
  getArchivedRooms,
@@ -178,7 +178,16 @@ export class FileStorage implements Storage {
178
178
  while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
179
179
  const lockFile = Bun.file(lockPath);
180
180
  if (await lockFile.exists()) {
181
- const lockContent = await lockFile.text();
181
+ // Read may throw if another writer deleted the lock between exists() and text()
182
+ // treat that as "lock is gone, proceed to acquire" by falling through.
183
+ let lockContent: string;
184
+ try {
185
+ lockContent = await lockFile.text();
186
+ } catch {
187
+ // Lock vanished in the race window — retry from top to re-check state cleanly.
188
+ await new Promise((r) => setTimeout(r, LOCK_RETRY_DELAY_MS));
189
+ continue;
190
+ }
182
191
  const lockTime = parseInt(lockContent, 10);
183
192
  if (!isNaN(lockTime) && Date.now() - lockTime > LOCK_TIMEOUT_MS) {
184
193
  try {
@@ -85,6 +85,8 @@ ROOM COMMANDS
85
85
 
86
86
  /capture
87
87
  Force-extract quotes, topics, and people from the current chat now.
88
+ /capture person <name> Re-scan all messages for a specific person.
89
+ /capture topic <name> Re-scan all messages for a specific topic.
88
90
 
89
91
  EXTENDED COMMANDS
90
92
  /tools
@@ -7,6 +7,9 @@ export function buildRoomYAMLTemplate(personas: PersonaSummary[], initialName =
7
7
  const personaLines = activePersonas.map((p) => ` ${p.display_name}: false`).join("\n");
8
8
  return `# Room configuration
9
9
  # mode: MAP | CYP | FFA
10
+ #
11
+ # CYP NOTE: Ei will NOT automatically extract Topics and People in Choose Your Path rooms.
12
+ # When you reach an important moment, use the /capture command to extract data manually.
10
13
  display_name: "${initialName}"
11
14
  mode: FFA
12
15
  persona_ids:
@@ -77,8 +77,8 @@ export function newProviderToYAML(name?: string): string {
77
77
 
78
78
  const modelsYAML = [
79
79
  "models:",
80
- " - name: (default)",
81
- " model_id: (default)",
80
+ " - name: default",
81
+ " model_id: default",
82
82
  " token_limit: null",
83
83
  " max_output_tokens: null",
84
84
  " thinking_budget: null",
@@ -168,8 +168,8 @@ export function providerToYAML(account: ProviderAccount): string {
168
168
  modelLines.push(` _delete: false`);
169
169
  }
170
170
  } else {
171
- modelLines.push(" - name: (default)");
172
- modelLines.push(` model_id: (default)`);
171
+ modelLines.push(" - name: default");
172
+ modelLines.push(` model_id: default`);
173
173
  modelLines.push(` token_limit: null`);
174
174
  modelLines.push(` max_output_tokens: null`);
175
175
  modelLines.push(` thinking_budget: null`);