ei-tui 0.9.2 → 0.9.4

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 (57) hide show
  1. package/package.json +17 -1
  2. package/src/README.md +1 -1
  3. package/src/cli/commands/personas.ts +11 -2
  4. package/src/cli/mcp.ts +2 -2
  5. package/src/cli/retrieval.ts +40 -7
  6. package/src/cli.ts +63 -72
  7. package/src/core/context-utils.ts +2 -2
  8. package/src/core/handlers/heartbeat.ts +9 -1
  9. package/src/core/handlers/human-extraction.ts +4 -1
  10. package/src/core/handlers/human-matching.ts +5 -53
  11. package/src/core/handlers/index.ts +1 -51
  12. package/src/core/handlers/persona-generation.ts +1 -28
  13. package/src/core/handlers/utils.ts +2 -9
  14. package/src/core/heartbeat-manager.ts +4 -4
  15. package/src/core/message-manager.ts +6 -5
  16. package/src/core/orchestrators/ceremony.ts +15 -13
  17. package/src/core/orchestrators/extraction-chunker.ts +3 -3
  18. package/src/core/orchestrators/human-extraction.ts +27 -39
  19. package/src/core/orchestrators/index.ts +0 -1
  20. package/src/core/orchestrators/persona-topics.ts +1 -1
  21. package/src/core/orchestrators/room-extraction.ts +45 -7
  22. package/src/core/processor.ts +8 -21
  23. package/src/core/prompt-context-builder.ts +68 -36
  24. package/src/core/state/personas.ts +1 -17
  25. package/src/core/state-manager.ts +0 -66
  26. package/src/core/types/entities.ts +2 -3
  27. package/src/core/types/enums.ts +0 -2
  28. package/src/core/types/rooms.ts +1 -1
  29. package/src/integrations/claude-code/importer.ts +1 -1
  30. package/src/integrations/cursor/importer.ts +1 -1
  31. package/src/integrations/opencode/importer.ts +1 -1
  32. package/src/prompts/ceremony/index.ts +0 -10
  33. package/src/prompts/ceremony/types.ts +1 -42
  34. package/src/prompts/generation/index.ts +0 -3
  35. package/src/prompts/generation/types.ts +0 -15
  36. package/src/prompts/heartbeat/check.ts +18 -6
  37. package/src/prompts/heartbeat/types.ts +2 -1
  38. package/src/prompts/human/index.ts +0 -2
  39. package/src/prompts/human/person-update.ts +6 -23
  40. package/src/prompts/human/types.ts +0 -16
  41. package/src/prompts/index.ts +0 -19
  42. package/src/prompts/reflection/index.ts +36 -4
  43. package/src/prompts/reflection/types.ts +1 -1
  44. package/src/prompts/response/index.ts +5 -0
  45. package/src/prompts/response/sections.ts +26 -0
  46. package/src/prompts/response/types.ts +3 -0
  47. package/tui/src/commands/registry.test.ts +10 -5
  48. package/tui/src/globals.d.ts +57 -0
  49. package/tui/src/util/yaml-persona.ts +8 -4
  50. package/tui/src/util/yaml-settings.ts +3 -3
  51. package/src/core/orchestrators/person-migration.ts +0 -55
  52. package/src/prompts/ceremony/description-check.ts +0 -54
  53. package/src/prompts/ceremony/expire.ts +0 -37
  54. package/src/prompts/ceremony/explore.ts +0 -77
  55. package/src/prompts/ceremony/person-migration.ts +0 -77
  56. package/src/prompts/generation/descriptions.ts +0 -91
  57. package/src/prompts/human/fact-scan.ts +0 -150
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,6 +51,22 @@
51
51
  "test:e2e:tui": "cd tui && npm run test:e2e",
52
52
  "test:e2e:ui": "playwright test --ui",
53
53
  "test:e2e:debug": "playwright test --debug",
54
+ "test:evals": "vite-node tests/evals/reflection-critic.eval.ts",
55
+ "test:evals:observe": "vite-node tests/evals/reflection-critic.observe.ts",
56
+ "test:evals:fact-find": "vite-node tests/evals/fact-find.eval.ts",
57
+ "test:evals:topic-scan": "vite-node tests/evals/topic-scan.eval.ts",
58
+ "test:evals:topic-match": "vite-node tests/evals/topic-match.eval.ts",
59
+ "test:evals:topic-update": "vite-node tests/evals/topic-update.eval.ts",
60
+ "test:evals:topic-validate": "vite-node tests/evals/topic-validate.eval.ts",
61
+ "test:evals:person-scan": "vite-node tests/evals/person-scan.eval.ts",
62
+ "test:evals:person-update": "vite-node tests/evals/person-update.eval.ts",
63
+ "test:evals:persona-trait": "vite-node tests/evals/persona-trait-extraction.eval.ts",
64
+ "test:evals:dedup": "vite-node tests/evals/dedup-tool-calls.eval.ts",
65
+ "test:evals:response-read-memory": "vite-node tests/evals/response-read-memory.eval.ts",
66
+ "test:evals:response-pending-update": "vite-node tests/evals/response-pending-update.eval.ts",
67
+ "test:evals:heartbeat-pending-update": "vite-node tests/evals/heartbeat-pending-update.eval.ts",
68
+ "test:evals:real-data": "vite-node tests/evals/real-data-example.eval.ts",
69
+ "test:evals:persona-data-check": "vite-node tests/evals/persona-data-check.eval.ts",
54
70
  "test:all": "npm run test && npm run test:e2e && npm run test:e2e:tui",
55
71
  "typecheck": "tsc --noEmit",
56
72
  "web": "cd web && npm run dev",
package/src/README.md CHANGED
@@ -148,7 +148,7 @@ This is intentional. Concurrent LLM calls sound appealing until you're watching
148
148
 
149
149
  # Context Windows
150
150
 
151
- Personas don't send their entire message history to the LLM. By default, only messages from the last 8 hours are included (`context_window_hours`, configurable per persona). Older messages are still stored — they're just not in the prompt.
151
+ Personas don't send their entire message history to the LLM. By default, only messages from the last 8 hours are included (`context_window_ms`, configurable per persona). Older messages are still stored — they're just not in the prompt.
152
152
 
153
153
  Message rolloff works differently: messages are kept until there are at least 200 of them _and_ any are older than 14 days. So a persona you chat with daily will roll off old messages gradually; one you chat with twice a year will keep everything.
154
154
 
@@ -1,4 +1,5 @@
1
- import { loadLatestState, retrievePersonas } from "../retrieval";
1
+ import { loadLatestState, retrievePersonas, retrievePersonasSemantic } from "../retrieval";
2
+ import { getEmbeddingService } from "../../core/embedding-service";
2
3
  import type { PersonaResult } from "../retrieval";
3
4
 
4
5
  export async function execute(query: string, limit: number, options: { recent?: boolean } = {}): Promise<PersonaResult[]> {
@@ -8,5 +9,13 @@ export async function execute(query: string, limit: number, options: { recent?:
8
9
  return [];
9
10
  }
10
11
 
11
- return retrievePersonas(query, state, limit, options);
12
+ const nameResults = retrievePersonas(query, state, limit, options);
13
+ if (nameResults.length > 0 || !query || options.recent) {
14
+ return nameResults;
15
+ }
16
+
17
+ const embeddingService = getEmbeddingService();
18
+ const queryVector = await embeddingService.embed(query);
19
+ const semanticResults = await retrievePersonasSemantic(queryVector, state, limit);
20
+ return semanticResults;
12
21
  }
package/src/cli/mcp.ts CHANGED
@@ -16,14 +16,14 @@ 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. 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).",
19
+ "Search the user's Ei knowledge base — a persistent memory store built from conversations. Returns facts, people, topics of interest, quotes, and personas. 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. Persona results include traits and topics that define the persona's identity and working style — use type='personas' with the persona's name OR a natural-language description of what they do to load a persona's character sheet. 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
23
23
  .enum(["facts", "people", "topics", "quotes", "personas"])
24
24
  .optional()
25
25
  .describe(
26
- "Filter to a specific data type. Omit to search all types (balanced across all 5)."
26
+ "Filter to a specific data type. Omit to search all types (balanced across all 5). For 'personas': matches by display name first, then falls back to semantic search of persona descriptions — use the persona's name (e.g. 'Sisyphus') or a description of their role (e.g. 'primary coding agent'). For 'people': semantic search on person descriptions. Note: 'personas' and 'people' are distinct — personas are AI agent identity records with traits/topics; people are human contacts."
27
27
  ),
28
28
  persona: z
29
29
  .string()
@@ -234,11 +234,36 @@ export function retrievePersonas(
234
234
  }
235
235
 
236
236
  const q = query.toLowerCase();
237
- return personaList
238
- .filter((p) => p.display_name.toLowerCase().includes(q))
239
- .sort((a, b) => b.last_updated.localeCompare(a.last_updated))
237
+ const nameMatches = personaList.filter((p) => p.display_name.toLowerCase().includes(q));
238
+ if (nameMatches.length > 0) {
239
+ return nameMatches
240
+ .sort((a, b) => b.last_updated.localeCompare(a.last_updated))
241
+ .slice(0, limit)
242
+ .map(mapPersona);
243
+ }
244
+
245
+ return [];
246
+ }
247
+
248
+ export async function retrievePersonasSemantic(
249
+ queryVector: number[],
250
+ state: StorageState,
251
+ limit: number = 10,
252
+ ): Promise<PersonaResult[]> {
253
+ const personaList = Object.values(state.personas).map((p) => p.entity);
254
+ const withEmbeddings = personaList
255
+ .filter((p): p is PersonaEntity & { description_embedding: number[] } => Array.isArray(p.description_embedding) && p.description_embedding.length > 0)
256
+ .map((p) => ({ id: p.id, embedding: p.description_embedding, _entity: p }));
257
+
258
+ if (withEmbeddings.length === 0) {
259
+ return [];
260
+ }
261
+
262
+ const scored = findTopK(queryVector, withEmbeddings, withEmbeddings.length);
263
+ return scored
264
+ .filter(({ similarity }) => similarity >= EMBEDDING_MIN_SIMILARITY)
240
265
  .slice(0, limit)
241
- .map(mapPersona);
266
+ .map(({ item }) => mapPersona((item as typeof withEmbeddings[number])._entity));
242
267
  }
243
268
 
244
269
  export async function retrieveBalanced(
@@ -305,7 +330,10 @@ export async function retrieveBalanced(
305
330
  .sort((a, b) => recentDate(b.mapped as AnyItem).localeCompare(recentDate(a.mapped as AnyItem)))
306
331
  .slice(0, limit)
307
332
  .map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
308
- const personaMatches = retrievePersonas(query, state, limit, { recent: true });
333
+ const personaMatches = [
334
+ ...retrievePersonas(query, state, limit, { recent: true }),
335
+ ...await retrievePersonasSemantic(queryVector, state, limit),
336
+ ].filter((p, i, arr) => arr.findIndex(x => x.id === p.id) === i);
309
337
  if (personaMatches.length > 0) {
310
338
  const combined = [
311
339
  ...personaMatches.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
@@ -352,7 +380,10 @@ export async function retrieveBalanced(
352
380
  result.sort((a, b) => b.similarity - a.similarity);
353
381
 
354
382
  const embeddingFinal = result.map(({ type, mapped }) => ({ type, ...mapped }) as BalancedResult);
355
- const personaFinal = retrievePersonas(query, state, limit);
383
+ const personaFinal = [
384
+ ...retrievePersonas(query, state, limit),
385
+ ...await retrievePersonasSemantic(queryVector, state, limit),
386
+ ].filter((p, i, arr) => arr.findIndex(x => x.id === p.id) === i);
356
387
  if (personaFinal.length > 0) {
357
388
  const combined = [
358
389
  ...personaFinal.map((p) => ({ type: "persona" as const, ...p }) as BalancedResult),
@@ -369,10 +400,12 @@ export async function lookupById(id: string): Promise<({ type: string } & Record
369
400
  return null;
370
401
  }
371
402
 
372
- const found = crossFind(id, state.human);
403
+ const personaEntities = Object.values(state.personas).map((p) => p.entity);
404
+ const found = crossFind(id, state.human, personaEntities);
373
405
  if (!found) return null;
374
406
  const { type, ...rest } = found;
375
407
  const withoutEmbedding = { ...rest } as Record<string, unknown>;
376
408
  delete withoutEmbedding.embedding;
409
+ delete withoutEmbedding.description_embedding;
377
410
  return { type, ...withoutEmbedding };
378
411
  }
package/src/cli.ts CHANGED
@@ -84,79 +84,50 @@ Examples:
84
84
  `);
85
85
  }
86
86
 
87
- function buildOpenCodeToolContent(): string {
88
- const lines = [
89
- 'import { tool } from "@opencode-ai/plugin"',
90
- '',
91
- 'export default tool({',
92
- ' description: [',
93
- ' "Search the user\'s Ei knowledge base \u2014 a persistent memory store built from conversations.",',
94
- ' "Returns facts, people, topics of interest, and quotes.",',
95
- ' "Use this to recall anything about the user: preferences, relationships, or past discussions.",',
96
- ' "Results include entity IDs that can be passed back with lookup=true to get full detail.",',
97
- ' ].join(" "),',
98
- ' args: {',
99
- ' query: tool.schema.string().optional().describe(',
100
- ' "Search text, or an entity ID when lookup=true. Supports natural language. Omit to browse by recency."',
101
- ' ),',
102
- ' type: tool.schema',
103
- ' .enum(["facts", "people", "topics", "quotes", "personas"])',
104
- ' .optional()',
105
- ' .describe(',
106
- ' "Filter to a specific data type. Omit to search all types (balanced across all 4).",',
107
- ' ),',
108
- ' persona: tool.schema',
109
- ' .string()',
110
- ' .optional()',
111
- ' .describe(',
112
- ' "Filter to entities a specific persona has learned about. Use the persona display name.",',
113
- ' ),',
114
- ' limit: tool.schema',
115
- ' .number()',
116
- ' .int()',
117
- ' .positive()',
118
- ' .default(10)',
119
- ' .optional()',
120
- ' .describe("Maximum number of results to return. Default: 10."),',
121
- ' lookup: tool.schema',
122
- ' .boolean()',
123
- ' .optional()',
124
- ' .describe(',
125
- ' "If true, treat query as an entity ID and return that single entity in full detail."',
126
- ' ),',
127
- ' recent: tool.schema',
128
- ' .boolean()',
129
- ' .optional()',
130
- ' .describe(',
131
- ' "If true, sort by most recently mentioned. Can be combined with persona or query."',
132
- ' ),',
133
- ' },',
134
- ' async execute(args) {',
135
- ' const cmd: string[] = ["ei"];',
136
- ' if (args.lookup) {',
137
- ' cmd.push("--id", args.query ?? "");',
138
- ' } else {',
139
- ' if (args.type) cmd.push(args.type);',
140
- ' if (args.persona) cmd.push("--persona", args.persona);',
141
- ' if (args.recent) cmd.push("--recent");',
142
- ' if (args.limit && args.limit !== 10) cmd.push("-n", String(args.limit));',
143
- ' if (args.query) cmd.push(args.query);',
144
- ' }',
145
- ' return Bun.$`${cmd}`.text();',
146
- ' },',
147
- '})',
148
- '',
149
- ];
150
- return lines.join('\n');
151
- }
152
87
 
153
- async function installOpenCodeTool(): Promise<void> {
154
- const toolsDir = join(process.env.HOME || "~", ".config", "opencode", "tools");
155
- const toolPath = join(toolsDir, "ei.ts");
88
+ async function installOpenCodeMcp(): Promise<void> {
89
+ const home = process.env.HOME || "~";
90
+ const opencodeDir = join(home, ".config", "opencode");
91
+ const opencodeJsoncPath = join(opencodeDir, "opencode.jsonc");
92
+
93
+ const eiDataPath = process.env.EI_DATA_PATH ?? (() => {
94
+ const xdgData = process.env.XDG_DATA_HOME || join(home, ".local", "share");
95
+ return join(xdgData, "ei");
96
+ })();
97
+
98
+ const mcpEntry = {
99
+ type: "local",
100
+ command: ["bunx", "ei-tui", "mcp"],
101
+ enabled: true,
102
+ environment: {
103
+ EI_DATA_PATH: eiDataPath,
104
+ },
105
+ };
106
+
107
+ let config: Record<string, unknown> = {};
108
+ try {
109
+ const rawText = await Bun.file(opencodeJsoncPath).text();
110
+ // Strip // line comments before parsing — opencode.jsonc uses line comments only
111
+ const stripped = rawText
112
+ .split("\n")
113
+ .map(line => line.replace(/\/\/.*$/, ""))
114
+ .join("\n");
115
+ config = JSON.parse(stripped) as Record<string, unknown>;
116
+ } catch {
117
+ // File doesn't exist or isn't valid — start fresh
118
+ }
119
+
120
+ const mcp = (config.mcp ?? {}) as Record<string, unknown>;
121
+ mcp["ei"] = mcpEntry;
122
+ config.mcp = mcp;
156
123
 
157
- await Bun.$`mkdir -p ${toolsDir}`;
158
- await Bun.write(toolPath, buildOpenCodeToolContent());
159
- console.log(`✓ Installed Ei tool to ${toolPath}`);
124
+ await Bun.$`mkdir -p ${opencodeDir}`;
125
+ const tmpPath = `${opencodeJsoncPath}.ei-install.tmp`;
126
+ await Bun.write(tmpPath, JSON.stringify(config, null, 2) + "\n");
127
+ const { rename } = await import(/* @vite-ignore */ "fs/promises");
128
+ await rename(tmpPath, opencodeJsoncPath);
129
+
130
+ console.log(`✓ Installed Ei MCP server to ~/.config/opencode/opencode.jsonc`);
160
131
  console.log(` Restart OpenCode to activate.`);
161
132
  }
162
133
 
@@ -257,8 +228,28 @@ async function main(): Promise<void> {
257
228
  }
258
229
 
259
230
  if (args[0] === "--install") {
260
- await installOpenCodeTool();
231
+ await installOpenCodeMcp();
261
232
  await installMcpClients();
233
+ console.log(`
234
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
235
+ Add this to ~/.config/opencode/AGENTS.md
236
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
237
+
238
+ **BEFORE YOU DO ANYTHING**: load your Identity from Ei.
239
+
240
+ Use the ei MCP tool:
241
+ ei_search(type: "personas", query: "[your name]")
242
+
243
+ Then load context for the current project/people:
244
+ ei_search("current projects teammates context")
245
+
246
+ Ei is persistent memory across sessions. Without querying it at
247
+ session start, you have no context from prior conversations.
248
+
249
+ **When to query again**: any time the user references past work,
250
+ mentions a person, or corrects something you assumed.
251
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
252
+ `);
262
253
  process.exit(0);
263
254
  }
264
255
 
@@ -8,12 +8,12 @@ import { ContextStatus as ContextStatusEnum } from "./types.js";
8
8
  export function filterMessagesForContext(
9
9
  messages: Message[],
10
10
  contextBoundary: string | undefined,
11
- contextWindowHours: number
11
+ contextWindowMs: number
12
12
  ): Message[] {
13
13
  if (messages.length === 0) return [];
14
14
 
15
15
  const now = Date.now();
16
- const windowStartMs = now - contextWindowHours * 60 * 60 * 1000;
16
+ const windowStartMs = now - contextWindowMs;
17
17
  const boundaryMs = contextBoundary ? new Date(contextBoundary).getTime() : 0;
18
18
 
19
19
  return messages.filter((msg) => {
@@ -126,13 +126,15 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
126
126
  const personaDisplayName = response.request.data.personaDisplayName as string;
127
127
 
128
128
  const result = response.parsed as ReflectionCriticResult | undefined;
129
- if (!result?.updated_identity || !result.critique) {
129
+ if (!result?.critique) {
130
130
  console.error(`[ReflectionCritic ${personaDisplayName}] Invalid or missing parsed result`);
131
131
  return;
132
132
  }
133
133
 
134
134
  const personRecord = state.human_person_getByIdentifier("Ei Persona", personaId);
135
135
  if (personRecord) {
136
+ // TODO: Remove before v1 — debug logging to inspect person log before it's cleared
137
+ console.log(`[ReflectionCritic ${personaDisplayName}] person_log_snapshot (${personRecord.description?.length ?? 0} chars): ${personRecord.description ?? ""}`);
136
138
  state.human_person_upsert({
137
139
  ...personRecord,
138
140
  description: "",
@@ -140,6 +142,12 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
140
142
  console.log(`[ReflectionCritic ${personaDisplayName}] Person record description cleared — ready for fresh evidence after reflection`);
141
143
  }
142
144
 
145
+ // Escape hatch: critic found no meaningful drift — log critique and skip pending_update
146
+ if (!result.updated_identity) {
147
+ console.log(`[ReflectionCritic ${personaDisplayName}] No drift detected — skipping pending_update. Critique: ${result.critique}`);
148
+ return;
149
+ }
150
+
143
151
  const persona = state.persona_getById(personaId);
144
152
  if (!persona) {
145
153
  console.error(`[ReflectionCritic ${personaDisplayName}] Persona not found after critic`);
@@ -189,7 +189,10 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
189
189
  return;
190
190
  }
191
191
 
192
- const context = response.request.data as unknown as ExtractionContext;
192
+ const context = {
193
+ ...(response.request.data as unknown as ExtractionContext),
194
+ channelDisplayName: (response.request.data as Record<string, unknown>).personaDisplayName as string,
195
+ };
193
196
  if (!context?.personaId) return;
194
197
 
195
198
  const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
@@ -8,7 +8,7 @@ import {
8
8
  import type { PersonIdentifier } from "../types/data-items.js";
9
9
  import type { StateManager } from "../state-manager.js";
10
10
  import type { ItemMatchResult, ExposureImpact, TopicUpdateResult, PersonUpdateResult } from "../../prompts/human/types.js";
11
- import { queueTopicUpdate, queuePersonUpdate, queueTopicValidate, type ExtractionContext } from "../orchestrators/index.js";
11
+ import { queueTopicUpdate, queueTopicValidate, type ExtractionContext } from "../orchestrators/index.js";
12
12
  import { getEmbeddingService, getTopicEmbeddingText, getPersonEmbeddingText } from "../embedding-service.js";
13
13
  import { calculateExposureCurrent } from "../utils/exposure.js";
14
14
 
@@ -48,7 +48,7 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
48
48
  extraction_model?: string;
49
49
  } = {
50
50
  personaId,
51
- personaDisplayName,
51
+ channelDisplayName: personaDisplayName,
52
52
  roomId,
53
53
  messages_context,
54
54
  messages_analyze,
@@ -64,54 +64,6 @@ export function handleTopicMatch(response: LLMResponse, state: StateManager): vo
64
64
  console.log(`[handleTopicMatch] topic "${context.candidateName}": ${matched}`);
65
65
  }
66
66
 
67
- export function handlePersonMatch(response: LLMResponse, state: StateManager): void {
68
- const result = response.parsed as ItemMatchResult | undefined;
69
- if (!result) {
70
- throw new Error("[handlePersonMatch] No parsed result");
71
- }
72
-
73
- const personaId = response.request.data.personaId as string;
74
- const personaDisplayName = response.request.data.personaDisplayName as string;
75
- const roomId = response.request.data.roomId as string | undefined;
76
- const { messages_context, messages_analyze } = resolveMessageWindow(response, state);
77
-
78
- let matched_guid = result.matched_guid;
79
- let resolvedPerson: import('../types/data-items.js').Person | null = null;
80
- if (matched_guid === "new") {
81
- matched_guid = null;
82
- } else if (matched_guid) {
83
- const human = state.getHuman();
84
- resolvedPerson = human.people.find(p => p.id === matched_guid) ?? null;
85
- if (!resolvedPerson) {
86
- console.warn(`[handlePersonMatch] matched_guid "${matched_guid}" not found in people — treating as new`);
87
- matched_guid = null;
88
- }
89
- }
90
- result.matched_guid = matched_guid;
91
-
92
- const context: ExtractionContext & {
93
- candidateName: string;
94
- candidateDescription: string;
95
- candidateRelationship: string;
96
- extraction_model?: string;
97
- } = {
98
- personaId,
99
- personaDisplayName,
100
- roomId,
101
- messages_context,
102
- messages_analyze,
103
- sources: response.request.data.sources as string[] | undefined,
104
- candidateName: response.request.data.candidateName as string,
105
- candidateDescription: response.request.data.candidateDescription as string,
106
- candidateRelationship: response.request.data.candidateRelationship as string,
107
- extraction_model: response.request.data.extraction_model as string | undefined,
108
- };
109
-
110
- queuePersonUpdate(result, context, state, resolvedPerson);
111
- const matched = matched_guid ? `matched GUID "${matched_guid}"` : "no match (new person)";
112
- console.log(`[handlePersonMatch] person "${context.candidateName}": ${matched}`);
113
- }
114
-
115
67
  export async function handleTopicUpdate(response: LLMResponse, state: StateManager): Promise<void> {
116
68
  const result = response.parsed as (TopicUpdateResult & { quotes?: Array<{ text: string; reason: string }> }) | undefined;
117
69
 
@@ -447,7 +399,7 @@ async function validateAndStoreQuotes(
447
399
  candidates: Array<{ text: string; reason: string }> | undefined,
448
400
  messages: Message[],
449
401
  dataItemId: string,
450
- personaName: string,
402
+ channelDisplayName: string,
451
403
  personaGroup: string | null,
452
404
  state: StateManager
453
405
  ): Promise<void> {
@@ -540,8 +492,8 @@ async function validateAndStoreQuotes(
540
492
  data_item_ids: [dataItemId],
541
493
  persona_groups: [personaGroup || "General"],
542
494
  text: matchText,
543
- speaker: message.role === "human" ? "human" : (message.speaker_name ?? personaName),
544
- channel: personaName,
495
+ speaker: message.role === "human" ? "human" : (message.speaker_name ?? channelDisplayName),
496
+ channel: channelDisplayName,
545
497
  timestamp: message.timestamp,
546
498
  start: matchStart,
547
499
  end: matchEnd,
@@ -1,14 +1,11 @@
1
1
  import { LLMNextStep } from "../types.js";
2
- import type { LLMResponse } from "../types.js";
3
- import type { StateManager } from "../state-manager.js";
4
2
  import type { ResponseHandler } from "./persona-response.js";
5
- import type { PersonIdentifier } from "../types/data-items.js";
6
3
 
7
4
  export type { ResponseHandler } from "./persona-response.js";
8
5
 
9
6
  import { handlePersonaResponse, handleToolContinuation, handleOneShot, handleOneShotJSON } from "./persona-response.js";
10
7
  import { handleHeartbeatCheck, handleEiHeartbeat, handleReflectionCritic } from "./heartbeat.js";
11
- import { handlePersonaGeneration, handlePersonaDescriptions, handlePersonaTraitExtraction } from "./persona-generation.js";
8
+ import { handlePersonaGeneration, handlePersonaTraitExtraction } from "./persona-generation.js";
12
9
  import {
13
10
  handlePersonaTopicRating,
14
11
  } from "./persona-topics.js";
@@ -19,55 +16,9 @@ import { handleDedupCurate } from "./dedup.js";
19
16
  import { handleRoomResponse, handleRoomJudge } from "./rooms.js";
20
17
  import { handlePersonaPreview } from "./persona-preview.js";
21
18
 
22
- function handlePersonIdentifierMigration(response: LLMResponse, state: StateManager): void {
23
- const personId = response.request.data.person_id as string;
24
- if (!personId) {
25
- console.error("[handlePersonIdentifierMigration] Missing person_id in request data");
26
- return;
27
- }
28
-
29
- const human = state.getHuman();
30
- const person = human.people.find(p => p.id === personId);
31
- if (!person) {
32
- console.error(`[handlePersonIdentifierMigration] Person not found: ${personId}`);
33
- return;
34
- }
35
-
36
- const result = response.parsed as { identifiers?: Array<{ type: string; value: string; is_primary?: boolean }> } | undefined;
37
- if (!result?.identifiers || !Array.isArray(result.identifiers) || result.identifiers.length === 0) {
38
- console.error(`[handlePersonIdentifierMigration] Invalid or empty identifiers for ${person.name}`);
39
- return;
40
- }
41
-
42
- const hasName = result.identifiers.some(i => i.value === person.name);
43
- if (!hasName) {
44
- result.identifiers.unshift({ type: "nickname", value: person.name });
45
- }
46
-
47
- const hasPrimary = result.identifiers.some(i => i.is_primary);
48
- if (!hasPrimary) {
49
- result.identifiers[0].is_primary = true;
50
- }
51
-
52
- const identifiers: PersonIdentifier[] = result.identifiers.map(i => ({
53
- type: i.type,
54
- value: i.value,
55
- ...(i.is_primary ? { is_primary: i.is_primary } : {}),
56
- }));
57
-
58
- state.human_person_upsert({
59
- ...person,
60
- identifiers,
61
- last_updated: new Date().toISOString(),
62
- });
63
-
64
- console.log(`[handlePersonIdentifierMigration] Migrated ${identifiers.length} identifier(s) for ${person.name}`);
65
- }
66
-
67
19
  export const handlers: Record<LLMNextStep, ResponseHandler> = {
68
20
  handlePersonaResponse,
69
21
  handlePersonaGeneration,
70
- handlePersonaDescriptions,
71
22
  handleFactFind,
72
23
  handleHumanTopicScan,
73
24
  handleHumanPersonScan,
@@ -88,7 +39,6 @@ export const handlers: Record<LLMNextStep, ResponseHandler> = {
88
39
  handleRoomResponse,
89
40
  handleRoomJudge,
90
41
  handlePersonaPreview,
91
- [LLMNextStep.HandlePersonIdentifierMigration]: handlePersonIdentifierMigration,
92
42
  [LLMNextStep.HandleTopicValidate]: handleDedupCurate,
93
43
  [LLMNextStep.HandleReflectionCritic]: handleReflectionCritic,
94
44
  };
@@ -4,7 +4,7 @@ import {
4
4
  type PersonaTopic,
5
5
  } from "../types.js";
6
6
  import type { StateManager } from "../state-manager.js";
7
- import type { PersonaGenerationResult, PersonaDescriptionsResult } from "../../prompts/generation/types.js";
7
+ import type { PersonaGenerationResult } from "../../prompts/generation/types.js";
8
8
  import type { TraitResult } from "../../prompts/persona/types.js";
9
9
  import { orchestratePersonaGeneration, type PartialPersona } from "../orchestrators/index.js";
10
10
 
@@ -111,33 +111,6 @@ export function handlePersonaGeneration(response: LLMResponse, state: StateManag
111
111
  console.log(`[handlePersonaGeneration] Orchestrated: ${personaDisplayName}`);
112
112
  }
113
113
 
114
- export function handlePersonaDescriptions(response: LLMResponse, state: StateManager): void {
115
- const personaId = response.request.data.personaId as string;
116
- const personaDisplayName = response.request.data.personaDisplayName as string;
117
- if (!personaId) {
118
- console.error("[handlePersonaDescriptions] No personaId in request data");
119
- return;
120
- }
121
-
122
- const result = response.parsed as PersonaDescriptionsResult | undefined;
123
- if (!result) {
124
- console.error("[handlePersonaDescriptions] No parsed result");
125
- return;
126
- }
127
-
128
- if (result.no_change) {
129
- console.log(`[handlePersonaDescriptions] No change needed for ${personaDisplayName}`);
130
- return;
131
- }
132
-
133
- state.persona_update(personaId, {
134
- short_description: result.short_description,
135
- long_description: result.long_description,
136
- last_updated: new Date().toISOString(),
137
- });
138
- console.log(`[handlePersonaDescriptions] Updated descriptions for ${personaDisplayName}`);
139
- }
140
-
141
114
  export function handlePersonaTraitExtraction(response: LLMResponse, state: StateManager): void {
142
115
  const personaId = response.request.data.personaId as string;
143
116
  const personaDisplayName = response.request.data.personaDisplayName as string;
@@ -1,15 +1,8 @@
1
1
  import type { Message, RoomMessage, LLMResponse } from "../types.js";
2
2
  import type { StateManager } from "../state-manager.js";
3
3
 
4
- export function getMessageContent(msg: { content?: string; verbal_response?: string; action_response?: string }): string {
5
- if (msg.content) return msg.content;
6
- // Legacy fallback for data not yet migrated on disk
7
- // TODO(v1.0.0): Remove legacy verbal_response/action_response fallback
8
- const legacy = msg as { verbal_response?: string; action_response?: string };
9
- const parts: string[] = [];
10
- if (legacy.action_response) parts.push(`_${legacy.action_response}_`);
11
- if (legacy.verbal_response) parts.push(legacy.verbal_response);
12
- return parts.join('\n\n');
4
+ export function getMessageContent(msg: { content?: string }): string {
5
+ return msg.content ?? '';
13
6
  }
14
7
 
15
8
  export function normalizeRoomMessages(messages: RoomMessage[], state: StateManager): Message[] {
@@ -217,15 +217,15 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
217
217
  console.log(`[HeartbeatCheck ${persona.display_name}] Queueing heartbeat check (model: ${model})`);
218
218
  const human = sm.getHuman();
219
219
  const history = sm.messages_get(personaId);
220
- const contextWindowHours = persona.context_window_hours ?? human.settings?.default_context_window_hours ?? 8;
221
- const contextHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowHours);
220
+ const contextWindowMs = persona.context_window_ms ?? human.settings?.default_context_window_ms ?? 28800000;
221
+ const contextHistory = filterMessagesForContext(history, persona.context_boundary, contextWindowMs);
222
222
 
223
223
  if (personaId === "ei") {
224
224
  await queueEiHeartbeat(sm, human, contextHistory, isTUI);
225
225
  return;
226
226
  }
227
227
 
228
- const filteredHuman = await filterHumanDataByVisibility(human, persona);
228
+ const filteredHuman = await filterHumanDataByVisibility(human, persona, []);
229
229
  const lastActivity = sm.messages_getLastActivity(persona.id);
230
230
  const inactiveDays = lastActivity
231
231
  ? Math.floor((Date.now() - lastActivity) / (1000 * 60 * 60 * 24))
@@ -244,7 +244,7 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
244
244
  name: persona.display_name,
245
245
  traits: persona.traits,
246
246
  topics: persona.topics,
247
- has_pending_update: !!persona.pending_update,
247
+ pending_update: persona.pending_update,
248
248
  },
249
249
  human: {
250
250
  topics: sortByEngagementGap(filteredHuman.topics).slice(0, 5),