ei-tui 0.6.6 → 0.7.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/src/cli/README.md +16 -7
  3. package/src/cli/commands/people.ts +1 -0
  4. package/src/cli/mcp.ts +36 -11
  5. package/src/cli/persona-filter.ts +42 -0
  6. package/src/cli/retrieval.ts +3 -1
  7. package/src/cli.ts +18 -6
  8. package/src/core/handlers/human-extraction.ts +1 -0
  9. package/src/core/handlers/human-matching.ts +20 -4
  10. package/src/core/handlers/index.ts +2 -1
  11. package/src/core/handlers/persona-response.ts +5 -0
  12. package/src/core/handlers/utils.ts +4 -1
  13. package/src/core/orchestrators/ceremony.ts +2 -2
  14. package/src/core/orchestrators/human-extraction.ts +24 -7
  15. package/src/core/persona-manager.ts +3 -0
  16. package/src/core/processor.ts +22 -2
  17. package/src/core/prompt-context-builder.ts +40 -10
  18. package/src/core/queue-manager.ts +18 -0
  19. package/src/core/room-manager.ts +21 -4
  20. package/src/core/state-manager.ts +74 -0
  21. package/src/core/tools/builtin/read-memory.ts +1 -1
  22. package/src/core/types/data-items.ts +1 -0
  23. package/src/core/types/entities.ts +13 -0
  24. package/src/core/types/enums.ts +1 -0
  25. package/src/core/types/integrations.ts +4 -0
  26. package/src/core/types/rooms.ts +2 -0
  27. package/src/core/utils/identifier-utils.ts +24 -0
  28. package/src/core/utils/theme-codec.ts +78 -0
  29. package/src/integrations/claude-code/importer.ts +3 -57
  30. package/src/integrations/cursor/importer.ts +2 -52
  31. package/src/integrations/opencode/importer.ts +1 -0
  32. package/src/prompts/response/sections.ts +1 -1
  33. package/src/prompts/response/types.ts +1 -0
  34. package/src/prompts/room/index.ts +2 -2
  35. package/src/prompts/room/sections.ts +4 -4
  36. package/src/prompts/room/types.ts +4 -0
  37. package/tui/src/commands/activate.tsx +7 -6
  38. package/tui/src/commands/context.tsx +188 -2
  39. package/tui/src/components/CYPTreeOverlay.tsx +357 -0
  40. package/tui/src/components/MAPScoreOverlay.tsx +300 -0
  41. package/tui/src/components/MessageList.tsx +14 -3
  42. package/tui/src/components/RoomMessageList.tsx +15 -3
  43. package/tui/src/context/ei.tsx +20 -0
  44. package/tui/src/util/cyp-tree.ts +62 -0
  45. package/tui/src/util/yaml-context.ts +87 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.6.6",
3
+ "version": "0.7.0",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
package/src/cli/README.md CHANGED
@@ -72,9 +72,11 @@ ei "What are the user's current preferences, active projects, and workflow?"
72
72
 
73
73
  Ei is a persistent knowledge base built from the user's conversations — facts, preferences,
74
74
  people, topics, personas. Use it when the user references past work, mentions how they like things done,
75
- or asks "how did we do X." Use `ei --persona "Beta" "walruses"` to scope results to what a specific
76
- persona has learned. Use `ei personas "name"` to find personas by name. Query again mid-session when
77
- they correct you or reference something from a previous session.
75
+ asks "how did we do X," or needs to look up a person by any name, handle, or account (GitHub username,
76
+ Discord handle, email, nickname, etc.) people results include an `identifiers` array covering all
77
+ known accounts and aliases for that person. Use `ei --persona "Beta" "walruses"` to scope results to
78
+ what a specific persona has learned. Use `ei personas "name"` to find personas by name. Query again
79
+ mid-session when they correct you or reference something from a previous session.
78
80
  ```
79
81
 
80
82
  ### Claude Code
@@ -87,9 +89,11 @@ natural-language query about the user's preferences, active projects, and workfl
87
89
  A `persona` filter is available to scope results to what a specific persona has learned.
88
90
  Use `type: "personas"` to search for personas by name.
89
91
 
90
- Use Ei when the user references past decisions, mentions people or preferences, or asks
91
- "how did we do X." Query again when they correct you or reference something from a previous
92
- session.
92
+ Use Ei when the user references past decisions, mentions people or preferences, asks
93
+ "how did we do X," or needs to look up a person by any name, handle, or account — people
94
+ results include an `identifiers` array (GitHub username, Discord handle, email, nickname, etc.)
95
+ covering all known accounts and aliases. Query again when they correct you or reference
96
+ something from a previous session.
93
97
  ```
94
98
 
95
99
  ### Cursor
@@ -111,6 +115,9 @@ conversations (facts, people, topics, quotes, personas).
111
115
  doesn't have that context.
112
116
  - You need the user's preferences, contacts, or project conventions (e.g. who to ask for
113
117
  access, how something was fixed).
118
+ - You need to look up a person by any name, handle, or account — people results include an
119
+ `identifiers` array (GitHub username, Discord handle, email, nickname, etc.) covering all
120
+ known accounts and aliases for that person.
114
121
  - The question is about the user personally (people, workflow, prior discussions) rather
115
122
  than only code.
116
123
 
@@ -138,7 +145,9 @@ The installed tool gives OpenCode agents access to all five data types with prop
138
145
 
139
146
  All search commands return arrays. Each result includes a `type` field.
140
147
 
141
- **Fact / Person / Topic**: `{ type, id, name, description, sentiment, ...type-specific fields }`
148
+ **Fact / Topic**: `{ type, id, name, description, sentiment, ...type-specific fields }`
149
+
150
+ **Person**: `{ type, id, name, description, relationship, sentiment, identifiers[] }` — `identifiers` contains all known accounts and aliases (e.g. `{ type: "GitHub", value: "flare576" }`)
142
151
 
143
152
  **Quote**: `{ type, id, text, speaker, timestamp, linked_items[] }`
144
153
 
@@ -21,5 +21,6 @@ export async function execute(query: string, limit: number, options: { recent?:
21
21
  description: person.description,
22
22
  relationship: person.relationship,
23
23
  sentiment: person.sentiment,
24
+ identifiers: person.identifiers ?? [],
24
25
  }));
25
26
  }
package/src/cli/mcp.ts CHANGED
@@ -3,7 +3,7 @@ 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
5
  import type { StorageState } from "../core/types/index.js";
6
- import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona } from "./persona-filter.js";
6
+ import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./persona-filter.js";
7
7
 
8
8
  // Exported so tests can inject their own transport
9
9
  export function createMcpServer(): McpServer {
@@ -16,7 +16,7 @@ export function createMcpServer(): McpServer {
16
16
  "ei_search",
17
17
  {
18
18
  description:
19
- "Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, and quotes. Results include entity IDs that can be passed back to ei_lookup for full detail. Omit query to browse by recency (use with recent=true or persona filter).",
19
+ "Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, and quotes. People results include an identifiers array (e.g. GitHub username, Discord handle, email, nickname) — query by any name or handle to find what Ei knows about that person. Results include entity IDs that can be passed back to ei_lookup for full detail. Omit query to browse by recency (use with recent=true or persona filter).",
20
20
  inputSchema: {
21
21
  query: z.string().optional().describe("Search text. Supports natural language. Omit to browse without semantic filtering — useful with recent=true or persona filter."),
22
22
  type: z
@@ -31,6 +31,12 @@ export function createMcpServer(): McpServer {
31
31
  .describe(
32
32
  "Filter to entities a specific persona has learned about. Use the persona display name."
33
33
  ),
34
+ source: z
35
+ .string()
36
+ .optional()
37
+ .describe(
38
+ "Filter to entities from a specific source. Prefix match against namespaced source identifiers (e.g. 'cursor', 'opencode', 'opencode:ses_abc123')."
39
+ ),
34
40
  limit: z
35
41
  .number()
36
42
  .optional()
@@ -42,16 +48,16 @@ export function createMcpServer(): McpServer {
42
48
  .describe("If true, sort by most recently mentioned."),
43
49
  },
44
50
  },
45
- async ({ query: rawQuery, type, persona, limit, recent }) => {
51
+ async ({ query: rawQuery, type, persona, source, limit, recent }) => {
46
52
  const query = rawQuery ?? "";
47
53
  const options = { recent: recent ?? false };
48
54
  const effectiveLimit = limit ?? 10;
49
55
 
50
56
  let state: StorageState | null = null;
51
57
  let personaId: string | undefined;
52
- if (persona) {
58
+ if (persona || source) {
53
59
  state = await loadLatestState();
54
- if (state) {
60
+ if (state && persona) {
55
61
  personaId = resolvePersonaId(state, persona) ?? undefined;
56
62
  if (!personaId) {
57
63
  return {
@@ -68,11 +74,17 @@ export function createMcpServer(): McpServer {
68
74
  if (personaId && state) {
69
75
  result = filterTypeSpecificByPersona(result as { id: string }[], state, personaId, type);
70
76
  }
77
+ if (source && state) {
78
+ result = filterTypeSpecificBySource(result as { id: string }[], state, source, type);
79
+ }
71
80
  } else {
72
81
  result = await retrieveBalanced(query, effectiveLimit, options);
73
82
  if (personaId && state) {
74
83
  result = filterByPersona(result as BalancedResult[], state, personaId);
75
84
  }
85
+ if (source && state) {
86
+ result = filterBySource(result as BalancedResult[], state, source);
87
+ }
76
88
  }
77
89
 
78
90
  return {
@@ -88,17 +100,30 @@ export function createMcpServer(): McpServer {
88
100
  "Look up a specific entity in the Ei knowledge base by ID. Returns the full entity record. Use IDs from ei_search results.",
89
101
  inputSchema: {
90
102
  id: z.string().describe("The entity ID to look up."),
103
+ source: z
104
+ .string()
105
+ .optional()
106
+ .describe(
107
+ "Filter to entities from a specific source. Prefix match against namespaced source identifiers (e.g. 'cursor', 'opencode', 'opencode:ses_abc123'). If the entity does not match, returns not found."
108
+ ),
91
109
  },
92
110
  },
93
- async ({ id }) => {
111
+ async ({ id, source }) => {
94
112
  const result = await lookupById(id);
95
- const text =
96
- result === null
97
- ? `No entity found with ID: ${id}`
98
- : JSON.stringify(result, null, 2);
113
+
114
+ if (result === null) {
115
+ return { content: [{ type: "text" as const, text: `No entity found with ID: ${id}` }] };
116
+ }
117
+
118
+ if (source) {
119
+ const sources = (result as { sources?: string[] }).sources;
120
+ if (!sources?.some((s) => s.startsWith(source))) {
121
+ return { content: [{ type: "text" as const, text: `No entity found with ID: ${id}` }] };
122
+ }
123
+ }
99
124
 
100
125
  return {
101
- content: [{ type: "text" as const, text }],
126
+ content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
102
127
  };
103
128
  }
104
129
  );
@@ -52,3 +52,45 @@ export function filterTypeSpecificByPersona<T extends { id: string }>(
52
52
  return original?.interested_personas?.includes(personaId) ?? false;
53
53
  });
54
54
  }
55
+
56
+ export function filterBySource(results: BalancedResult[], state: StorageState, sourcePrefix: string): BalancedResult[] {
57
+ return results.filter((result) => {
58
+ if (result.type === "quote") {
59
+ return false;
60
+ }
61
+ const { id } = result;
62
+ let original: { sources?: string[] } | undefined;
63
+ if (result.type === "fact") {
64
+ original = state.human.facts.find((f) => f.id === id);
65
+ } else if (result.type === "topic") {
66
+ original = state.human.topics.find((t) => t.id === id);
67
+ } else if (result.type === "person") {
68
+ original = state.human.people.find((p) => p.id === id);
69
+ }
70
+ return original?.sources?.some((s) => s.startsWith(sourcePrefix)) ?? false;
71
+ });
72
+ }
73
+
74
+ export function filterTypeSpecificBySource<T extends { id: string }>(
75
+ results: T[],
76
+ state: StorageState,
77
+ sourcePrefix: string,
78
+ targetType: string
79
+ ): T[] {
80
+ if (targetType === "quotes") {
81
+ return [];
82
+ }
83
+ const collection =
84
+ targetType === "facts"
85
+ ? state.human.facts
86
+ : targetType === "topics"
87
+ ? state.human.topics
88
+ : targetType === "people"
89
+ ? state.human.people
90
+ : null;
91
+ if (!collection) return results;
92
+ return results.filter((r) => {
93
+ const original = collection.find((item) => item.id === r.id) as { sources?: string[] } | undefined;
94
+ return original?.sources?.some((s) => s.startsWith(sourcePrefix)) ?? false;
95
+ });
96
+ }
@@ -1,6 +1,6 @@
1
1
  import type { StorageState, Quote, Fact, Person, Topic } from "../core/types";
2
2
  import type { PersonaEntity } from "../core/types/entities.js";
3
- import type { PersonaTrait, PersonaTopic } from "../core/types/data-items.js";
3
+ import type { PersonaTrait, PersonaTopic, PersonIdentifier } from "../core/types/data-items.js";
4
4
  import { decodeAllEmbeddings } from "../storage/embeddings";
5
5
  import { crossFind } from "../core/utils/index.ts";
6
6
  import { join } from "path";
@@ -102,6 +102,7 @@ export interface PersonResult {
102
102
  description: string;
103
103
  relationship: string;
104
104
  sentiment: number;
105
+ identifiers: PersonIdentifier[];
105
106
  }
106
107
 
107
108
  export interface TopicResult {
@@ -182,6 +183,7 @@ function mapPerson(person: Person): PersonResult {
182
183
  description: person.description,
183
184
  relationship: person.relationship,
184
185
  sentiment: person.sentiment,
186
+ identifiers: person.identifiers ?? [],
185
187
  };
186
188
  }
187
189
 
package/src/cli.ts CHANGED
@@ -15,7 +15,7 @@ import { parseArgs } from "util";
15
15
  import { join } from "path";
16
16
  import { retrieveBalanced, lookupById, loadLatestState } from "./cli/retrieval";
17
17
  import type { StorageState } from "./core/types";
18
- import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona } from "./cli/persona-filter.js";
18
+ import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./cli/persona-filter.js";
19
19
 
20
20
  const TYPE_ALIASES: Record<string, string> = {
21
21
  quote: "quotes",
@@ -59,6 +59,7 @@ Options:
59
59
  --number, -n Maximum number of results (default: 10)
60
60
  --recent, -r Sort by last_mentioned date (most recent first)
61
61
  --persona, -p Filter to entities a specific persona has learned about
62
+ --source, -s Filter to entities from a specific source (prefix match, e.g. "cursor", "opencode:ses_abc123")
62
63
  --id Look up entity by ID (accepts value or stdin)
63
64
  --install Register Ei with OpenCode, Claude Code, and Cursor
64
65
  --help, -h Show this help message
@@ -70,6 +71,7 @@ Examples:
70
71
  ei --recent # Most recently mentioned items
71
72
  ei topics --recent "work" # Recent work-related topics
72
73
  ei --persona "Architect" "work stuff" # What Architect knows about work
74
+ ei topics --source cursor "X" # Topics learned from Cursor sessions
73
75
  ei --id abc-123 # Look up entity by ID
74
76
  ei "memory leak" | jq .[0].id | ei --id # Pipe ID from search
75
77
  `);
@@ -305,6 +307,7 @@ async function main(): Promise<void> {
305
307
  number: { type: "string", short: "n" },
306
308
  recent: { type: "boolean", short: "r" },
307
309
  persona: { type: "string", short: "p" },
310
+ source: { type: "string", short: "s" },
308
311
  help: { type: "boolean", short: "h" },
309
312
  },
310
313
  allowPositionals: true,
@@ -325,6 +328,7 @@ async function main(): Promise<void> {
325
328
  // Default to recent mode when no query — allows `ei --persona Foo` and `ei` with no args
326
329
  const recent = parsed.values.recent === true || !query;
327
330
  const personaName = parsed.values.persona?.trim();
331
+ const sourcePrefix = parsed.values.source?.trim();
328
332
 
329
333
  if (isNaN(limit) || limit < 1) {
330
334
  console.error("--number must be a positive integer");
@@ -333,16 +337,18 @@ async function main(): Promise<void> {
333
337
 
334
338
  let state: StorageState | null = null;
335
339
  let personaId: string | undefined;
336
- if (personaName) {
340
+ if (personaName || sourcePrefix) {
337
341
  state = await loadLatestState();
338
342
  if (!state) {
339
343
  console.error("No saved state found. Is EI_DATA_PATH set correctly?");
340
344
  process.exit(1);
341
345
  }
342
- personaId = resolvePersonaId(state, personaName) ?? undefined;
343
- if (!personaId) {
344
- console.error(`Persona "${personaName}" not found.`);
345
- process.exit(1);
346
+ if (personaName) {
347
+ personaId = resolvePersonaId(state, personaName) ?? undefined;
348
+ if (!personaId) {
349
+ console.error(`Persona "${personaName}" not found.`);
350
+ process.exit(1);
351
+ }
346
352
  }
347
353
  }
348
354
 
@@ -355,11 +361,17 @@ async function main(): Promise<void> {
355
361
  if (personaId && state) {
356
362
  result = filterTypeSpecificByPersona(result, state, personaId, targetType);
357
363
  }
364
+ if (sourcePrefix && state) {
365
+ result = filterTypeSpecificBySource(result, state, sourcePrefix, targetType);
366
+ }
358
367
  } else {
359
368
  result = await retrieveBalanced(query, limit, options);
360
369
  if (personaId && state) {
361
370
  result = filterByPersona(result, state, personaId);
362
371
  }
372
+ if (sourcePrefix && state) {
373
+ result = filterBySource(result, state, sourcePrefix);
374
+ }
363
375
  }
364
376
 
365
377
  console.log(JSON.stringify(result, null, 2));
@@ -147,6 +147,7 @@ export async function handleFactFind(response: LLMResponse, state: StateManager)
147
147
  learned_by: existingFact.learned_by ?? context.personaId,
148
148
  last_changed_by: context.personaId,
149
149
  interested_personas: [...new Set([...(existingFact.interested_personas ?? []), context.personaId])],
150
+ sources: [...new Set([...(existingFact.sources ?? []), ...(context.sources ?? [])])],
150
151
  embedding,
151
152
  };
152
153
 
@@ -14,7 +14,7 @@ import { calculateExposureCurrent } from "../utils/exposure.js";
14
14
 
15
15
 
16
16
  import { resolveMessageWindow, getMessageText, normalizeRoomMessages } from "./utils.js";
17
- import { sanitizeEiPersonaIdentifiers } from "../utils/identifier-utils.js";
17
+ import { sanitizeEiPersonaIdentifiers, normalizeIdentifierType } from "../utils/identifier-utils.js";
18
18
 
19
19
  export function handleTopicMatch(response: LLMResponse, state: StateManager): void {
20
20
  const result = response.parsed as ItemMatchResult | undefined;
@@ -176,6 +176,10 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
176
176
  const personaGroupsMerged = isNewItem
177
177
  ? (allPersonaGroups.length > 0 ? allPersonaGroups : existingTopic?.persona_groups)
178
178
  : [...new Set([...(existingTopic?.persona_groups ?? []), ...allPersonaGroups])];
179
+ const incomingSources = (response.request.data.sources ?? []) as string[];
180
+ const sources = isNewItem
181
+ ? incomingSources
182
+ : [...new Set([...(existingTopic?.sources ?? []), ...incomingSources])];
179
183
 
180
184
  const topic: Topic = {
181
185
  id: itemId,
@@ -191,6 +195,7 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
191
195
  learned_by: isNewItem ? primaryId : existingTopic?.learned_by,
192
196
  last_changed_by: primaryId,
193
197
  interested_personas: interestedPersonas,
198
+ sources: sources.length > 0 ? sources : undefined,
194
199
  persona_groups: personaGroupsMerged,
195
200
  embedding,
196
201
  };
@@ -279,12 +284,16 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
279
284
  const personaGroupsMerged = isNewItem
280
285
  ? (allPersonaGroups.length > 0 ? allPersonaGroups : existingPerson?.persona_groups)
281
286
  : [...new Set([...(existingPerson?.persona_groups ?? []), ...allPersonaGroups])];
287
+ const incomingPersonSources = (response.request.data.sources ?? []) as string[];
288
+ const personSources = isNewItem
289
+ ? incomingPersonSources
290
+ : [...new Set([...(existingPerson?.sources ?? []), ...incomingPersonSources])];
282
291
 
283
292
  let resolvedIdentifiers: PersonIdentifier[];
284
293
  if (isNewItem) {
285
294
  const llmIdentifiers: PersonIdentifier[] = sanitizeEiPersonaIdentifiers(
286
295
  (result.identifiers ?? []).map(i => ({
287
- type: i.type,
296
+ type: normalizeIdentifierType(i.type, state),
288
297
  value: i.value,
289
298
  ...(i.is_primary ? { is_primary: i.is_primary } : {}),
290
299
  })),
@@ -293,7 +302,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
293
302
  const allCandidateIds = [...llmIdentifiers, ...candidateIdentifiers];
294
303
  if (allCandidateIds.length === 0) {
295
304
  const hasSpace = candidateName.includes(' ');
296
- allCandidateIds.push({ type: hasSpace ? "full_name" : "nickname", value: candidateName, is_primary: true });
305
+ allCandidateIds.push({ type: hasSpace ? "Full Name" : "Nickname", value: candidateName, is_primary: true });
297
306
  }
298
307
  const deduped: PersonIdentifier[] = [];
299
308
  for (const id of allCandidateIds) {
@@ -304,7 +313,13 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
304
313
  resolvedIdentifiers = deduped;
305
314
  } else {
306
315
  const base = [...(existingPerson?.identifiers ?? [])];
307
- const sanitizedToAdd = sanitizeEiPersonaIdentifiers(result.identifiers_to_add ?? [], state);
316
+ const sanitizedToAdd = sanitizeEiPersonaIdentifiers(
317
+ (result.identifiers_to_add ?? []).map(i => ({
318
+ ...i,
319
+ type: normalizeIdentifierType(i.type, state),
320
+ })),
321
+ state
322
+ );
308
323
  for (const id of sanitizedToAdd) {
309
324
  if (!base.some(e => e.value === id.value)) {
310
325
  base.push({ type: id.type, value: id.value, ...(id.is_primary ? { is_primary: id.is_primary } : {}) });
@@ -329,6 +344,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
329
344
  learned_by: isNewItem ? primaryId : existingPerson?.learned_by,
330
345
  last_changed_by: primaryId,
331
346
  interested_personas: interestedPersonas,
347
+ sources: personSources.length > 0 ? personSources : undefined,
332
348
  persona_groups: personaGroupsMerged,
333
349
  embedding,
334
350
  };
@@ -6,7 +6,7 @@ import type { PersonIdentifier } from "../types/data-items.js";
6
6
 
7
7
  export type { ResponseHandler } from "./persona-response.js";
8
8
 
9
- import { handlePersonaResponse, handleToolContinuation, handleOneShot } from "./persona-response.js";
9
+ import { handlePersonaResponse, handleToolContinuation, handleOneShot, handleOneShotJSON } from "./persona-response.js";
10
10
  import { handleHeartbeatCheck, handleEiHeartbeat } from "./heartbeat.js";
11
11
  import { handlePersonaGeneration, handlePersonaDescriptions, handlePersonaTraitExtraction } from "./persona-generation.js";
12
12
  import {
@@ -79,6 +79,7 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
79
79
  handleHeartbeatCheck,
80
80
  handleEiHeartbeat,
81
81
  handleOneShot,
82
+ handleOneShotJSON,
82
83
  handleToolContinuation,
83
84
  handleRewriteScan,
84
85
  handleRewriteRewrite,
@@ -139,3 +139,8 @@ export function handleOneShot(_response: LLMResponse, _state: StateManager): voi
139
139
  // One-shot is handled specially in Processor to fire onOneShotReturned
140
140
  // This handler is a no-op placeholder
141
141
  }
142
+
143
+ export function handleOneShotJSON(_response: LLMResponse, _state: StateManager): void {
144
+ // One-shot JSON is handled specially in Processor to fire onOneShotJSONReturned
145
+ // This handler is a no-op placeholder
146
+ }
@@ -11,7 +11,10 @@ export function getMessageContent(msg: { content?: string; verbal_response?: str
11
11
 
12
12
  export function normalizeRoomMessages(messages: RoomMessage[], state: StateManager): Message[] {
13
13
  const human = state.getHuman();
14
- const humanName = human.settings?.name_display ?? "Human";
14
+ const humanName =
15
+ human.settings?.name_display ||
16
+ human.facts?.find(f => f.name === "Nickname/Preferred Name")?.description ||
17
+ "Human";
15
18
  return messages.map(m => {
16
19
  const speakerName = m.role === "human"
17
20
  ? humanName
@@ -1,4 +1,4 @@
1
- import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, type CeremonyConfig, type PersonaTopic, type Topic, type DataItemBase } from "../types.js";
1
+ import { LLMRequestType, LLMPriority, LLMNextStep, RoomMode, ContextStatus, type CeremonyConfig, type PersonaTopic, type Topic, type DataItemBase } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
  import { normalizeRoomMessages } from "../handlers/utils.js";
4
4
  import { applyDecayToValue } from "../utils/index.js";
@@ -347,7 +347,7 @@ export function prunePersonaMessages(personaId: string, state: StateManager): vo
347
347
  if (msgMs >= cutoffMs) break; // Sorted by time, no more old ones
348
348
 
349
349
  const fullyExtracted = m.t && m.p && m.f; // r intentionally excluded — trait extraction deprecated
350
- if (fullyExtracted) {
350
+ if (fullyExtracted && m.context_status !== ContextStatus.Always) {
351
351
  toRemove.push(m.id);
352
352
  }
353
353
  }
@@ -59,6 +59,7 @@ export interface ExtractionContext {
59
59
  messages_analyze: Message[];
60
60
  extraction_flag?: "f" | "t" | "p" | "e";
61
61
  roomId?: string;
62
+ sources?: string[];
62
63
  }
63
64
 
64
65
  export interface ExtractionOptions {
@@ -132,6 +133,7 @@ export function queueFactFind(context: ExtractionContext, state: StateManager, o
132
133
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
133
134
  extraction_flag: context.extraction_flag,
134
135
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
136
+ sources: context.sources,
135
137
  },
136
138
  });
137
139
  }
@@ -173,6 +175,7 @@ export function queueTopicScan(context: ExtractionContext, state: StateManager,
173
175
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
174
176
  extraction_flag: context.extraction_flag,
175
177
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
178
+ sources: context.sources,
176
179
  },
177
180
  });
178
181
  }
@@ -222,6 +225,7 @@ export function queuePersonScan(context: ExtractionContext, state: StateManager,
222
225
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
223
226
  extraction_flag: context.extraction_flag,
224
227
  message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
228
+ sources: context.sources,
225
229
  },
226
230
  });
227
231
  }
@@ -281,6 +285,7 @@ export function queueDirectTopicUpdate(
281
285
  isNewItem: false,
282
286
  existingItemId: topic.id,
283
287
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
288
+ extraction_model: extractionModel,
284
289
  },
285
290
  });
286
291
  }
@@ -425,14 +430,11 @@ export function queueTopicUpdate(
425
430
  user: prompt.user,
426
431
  next_step: LLMNextStep.HandleTopicUpdate,
427
432
  data: {
428
- personaId: context.personaId,
429
- personaDisplayName: context.personaDisplayName,
430
- roomId: context.roomId,
433
+ ...context,
431
434
  isNewItem,
432
435
  existingItemId: existingItem?.id,
433
436
  candidateName: isNewItem ? context.candidateName : undefined,
434
437
  candidateDescription: isNewItem ? context.candidateDescription : undefined,
435
- candidateCategory: context.candidateCategory,
436
438
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
437
439
  },
438
440
  });
@@ -469,13 +471,25 @@ export function queueEventSummary(
469
471
 
470
472
  const allMessages = state.messages_get(personaId);
471
473
  const extractionModel = options?.extraction_model;
474
+ const gapMs = gapHours * 60 * 60 * 1000;
475
+ const now = Date.now();
472
476
  let totalChunks = 0;
473
477
 
474
- state.messages_markExtracted(personaId, sorted.map(m => m.id), "e");
475
-
476
- for (const windowMessages of windows) {
478
+ for (let i = 0; i < windows.length; i++) {
479
+ const windowMessages = windows[i];
477
480
  if (windowMessages.length === 0) continue;
478
481
 
482
+ const isLastWindow = i === windows.length - 1;
483
+ if (isLastWindow) {
484
+ const lastMsgTime = new Date(windowMessages[windowMessages.length - 1].timestamp).getTime();
485
+ if (now - lastMsgTime < gapMs) {
486
+ console.log(`[queueEventSummary] Skipping open window for ${persona.display_name} — last message < ${gapHours}h ago`);
487
+ continue;
488
+ }
489
+ }
490
+
491
+ state.messages_markExtracted(personaId, windowMessages.map(m => m.id), "e");
492
+
479
493
  const windowStartTime = new Date(windowMessages[0].timestamp).getTime();
480
494
  const messages_context = allMessages.filter(
481
495
  m => m.e === true && new Date(m.timestamp).getTime() < windowStartTime
@@ -599,6 +613,8 @@ export function queuePersonUpdate(
599
613
  candidateRelationship: context.candidateRelationship,
600
614
  candidateIdentifiers: isNewItem ? candidateIdentifiers : undefined,
601
615
  analyze_from_timestamp: getAnalyzeFromTimestamp(chunk),
616
+ extraction_model: context.extraction_model,
617
+ sources: context.sources,
602
618
  },
603
619
  });
604
620
  }
@@ -653,6 +669,7 @@ export async function queueTopicValidate(
653
669
  data: {
654
670
  entity_type: "topic",
655
671
  entity_ids: [existingTopic.id, newTopic.id],
672
+ extraction_model: extractionModel,
656
673
  },
657
674
  });
658
675
  }
@@ -20,6 +20,9 @@ export async function getPersonaList(sm: StateManager): Promise<PersonaSummary[]
20
20
  unread_count: sm.messages_countUnread(entity.id),
21
21
  last_activity: entity.last_activity,
22
22
  context_boundary: entity.context_boundary,
23
+ avatar_emoji: entity.avatar_emoji,
24
+ avatar_image: entity.avatar_image,
25
+ preferred_theme: entity.preferred_theme,
23
26
  }));
24
27
  }
25
28
 
@@ -112,6 +112,7 @@ import {
112
112
  deleteQueueItems,
113
113
  clearQueue,
114
114
  submitOneShot,
115
+ submitOneShotJSON,
115
116
  } from "./queue-manager.js";
116
117
  import {
117
118
  getRoomList,
@@ -1354,7 +1355,7 @@ const toolNextSteps = new Set([
1354
1355
  if (result.sessionsProcessed > 0) {
1355
1356
  console.log(
1356
1357
  `[Processor] Claude Code sync complete: ${result.sessionsProcessed} sessions, ` +
1357
- `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
1358
+ `${result.messagesImported} messages imported, ` +
1358
1359
  `${result.extractionScansQueued} extraction scans queued`
1359
1360
  );
1360
1361
  }
@@ -1407,7 +1408,7 @@ const toolNextSteps = new Set([
1407
1408
  if (result.sessionsProcessed > 0) {
1408
1409
  console.log(
1409
1410
  `[Processor] Cursor sync complete: ${result.sessionsProcessed} sessions, ` +
1410
- `${result.topicsCreated} topics created, ${result.messagesImported} messages imported, ` +
1411
+ `${result.messagesImported} messages imported, ` +
1411
1412
  `${result.extractionScansQueued} extraction scans queued`
1412
1413
  );
1413
1414
  }
@@ -1484,6 +1485,10 @@ const toolNextSteps = new Set([
1484
1485
  const guid = response.request.data.guid as string;
1485
1486
  this.interface.onOneShotReturned?.(guid, "");
1486
1487
  }
1488
+ if (response.request.next_step === LLMNextStep.HandleOneShotJSON) {
1489
+ const guid = response.request.data.guid as string;
1490
+ this.interface.onOneShotJSONReturned?.(guid, null);
1491
+ }
1487
1492
  }
1488
1493
 
1489
1494
  this.interface.onError?.({ code, message });
@@ -1529,6 +1534,11 @@ const toolNextSteps = new Set([
1529
1534
  this.interface.onOneShotReturned?.(guid, content);
1530
1535
  }
1531
1536
 
1537
+ if (response.request.next_step === LLMNextStep.HandleOneShotJSON) {
1538
+ const guid = response.request.data.guid as string;
1539
+ this.interface.onOneShotJSONReturned?.(guid, response.parsed ?? null);
1540
+ }
1541
+
1532
1542
  if (response.request.next_step === LLMNextStep.HandlePersonaPreview) {
1533
1543
  const guid = response.request.data.guid as string;
1534
1544
  const loopCounter = (response.request.data.loop_counter as number) ?? 0;
@@ -1994,6 +2004,16 @@ const toolNextSteps = new Set([
1994
2004
  );
1995
2005
  }
1996
2006
 
2007
+ async submitOneShotJSON(guid: string, systemPrompt: string, userPrompt: string): Promise<void> {
2008
+ return submitOneShotJSON(
2009
+ this.stateManager,
2010
+ () => getOneshotModel(this.stateManager),
2011
+ guid,
2012
+ systemPrompt,
2013
+ userPrompt
2014
+ );
2015
+ }
2016
+
1997
2017
  async generatePersonaPreview(
1998
2018
  name: string,
1999
2019
  description: string,