ei-tui 0.9.1 → 0.9.3
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 +14 -1
- package/src/cli/commands/personas.ts +11 -2
- package/src/cli/mcp.ts +2 -2
- package/src/cli/retrieval.ts +40 -7
- package/src/cli.ts +63 -72
- package/src/core/handlers/heartbeat.ts +2 -2
- package/src/core/handlers/human-extraction.ts +9 -0
- package/src/core/heartbeat-manager.ts +1 -1
- package/src/core/message-manager.ts +4 -2
- package/src/core/orchestrators/ceremony.ts +44 -5
- package/src/core/orchestrators/human-extraction.ts +12 -22
- package/src/core/orchestrators/index.ts +1 -0
- package/src/core/orchestrators/room-extraction.ts +40 -2
- package/src/core/processor.ts +38 -1
- package/src/core/prompt-context-builder.ts +61 -30
- package/src/prompts/human/person-update.ts +6 -23
- package/src/prompts/reflection/index.ts +3 -1
- package/tui/src/commands/persona.tsx +3 -4
- package/tui/src/commands/reflect.tsx +2 -8
- package/tui/src/context/ei.tsx +12 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ei-tui",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"author": "Flare576",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -51,6 +51,19 @@
|
|
|
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:real-data": "vite-node tests/evals/real-data-example.eval.ts",
|
|
54
67
|
"test:all": "npm run test && npm run test:e2e && npm run test:e2e:tui",
|
|
55
68
|
"typecheck": "tsc --noEmit",
|
|
56
69
|
"web": "cd web && npm run dev",
|
|
@@ -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
|
-
|
|
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
|
|
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()
|
package/src/cli/retrieval.ts
CHANGED
|
@@ -234,11 +234,36 @@ export function retrievePersonas(
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
const q = query.toLowerCase();
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
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
|
|
154
|
-
const
|
|
155
|
-
const
|
|
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 ${
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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
|
|
|
@@ -135,9 +135,9 @@ export function handleReflectionCritic(response: LLMResponse, state: StateManage
|
|
|
135
135
|
if (personRecord) {
|
|
136
136
|
state.human_person_upsert({
|
|
137
137
|
...personRecord,
|
|
138
|
-
description:
|
|
138
|
+
description: "",
|
|
139
139
|
});
|
|
140
|
-
console.log(`[ReflectionCritic ${personaDisplayName}] Person record description
|
|
140
|
+
console.log(`[ReflectionCritic ${personaDisplayName}] Person record description cleared — ready for fresh evidence after reflection`);
|
|
141
141
|
}
|
|
142
142
|
|
|
143
143
|
const persona = state.persona_getById(personaId);
|
|
@@ -298,6 +298,15 @@ export async function handleHumanPersonScan(response: LLMResponse, state: StateM
|
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
300
|
|
|
301
|
+
if (matchedPerson && response.request.data.reflection_progress === 1) {
|
|
302
|
+
const linkedPersonaId = matchedPerson.identifiers
|
|
303
|
+
?.find(i => i.type === "Ei Persona")?.value;
|
|
304
|
+
if (linkedPersonaId) {
|
|
305
|
+
console.log(`[handleHumanPersonScan] Skipping update for "${candidate.name}" — scan marked as reflection drain (reflection_progress=1)`);
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
301
310
|
const matchResult: ItemMatchResult = { matched_guid: matchedPerson?.id ?? null };
|
|
302
311
|
queuePersonUpdate(matchResult, {
|
|
303
312
|
...context,
|
|
@@ -225,7 +225,7 @@ export async function queueHeartbeatCheck(sm: StateManager, personaId: string, i
|
|
|
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))
|
|
@@ -253,6 +253,8 @@ export function checkAndQueueHumanExtraction(
|
|
|
253
253
|
const unextractedPeople = sm.messages_getUnextracted(personaId, "p", undefined, "exclude");
|
|
254
254
|
const peopleThreshold = Math.min(EXTRACTION_TAPER_CAP, human.people.length);
|
|
255
255
|
if (unextractedPeople.length > 0 && unextractedPeople.length >= peopleThreshold) {
|
|
256
|
+
const personaForScan = sm.persona_getById(personaId);
|
|
257
|
+
const personScanOptions = personaForScan?.pending_update ? { reflection_progress: 1 } : undefined;
|
|
256
258
|
const context: ExtractionContext = {
|
|
257
259
|
personaId,
|
|
258
260
|
personaDisplayName,
|
|
@@ -260,9 +262,9 @@ export function checkAndQueueHumanExtraction(
|
|
|
260
262
|
messages_analyze: unextractedPeople,
|
|
261
263
|
extraction_flag: "p",
|
|
262
264
|
};
|
|
263
|
-
queuePersonScan(context, sm);
|
|
265
|
+
queuePersonScan(context, sm, personScanOptions);
|
|
264
266
|
console.log(
|
|
265
|
-
`[Processor] Human Seed extraction: people (threshold: ${peopleThreshold}, unextracted: ${unextractedPeople.length})`
|
|
267
|
+
`[Processor] Human Seed extraction: people (threshold: ${peopleThreshold}, unextracted: ${unextractedPeople.length}${personScanOptions ? ", reflection_progress=1" : ""})`
|
|
266
268
|
);
|
|
267
269
|
}
|
|
268
270
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ExtractionOptions,
|
|
12
12
|
} from "./human-extraction.js";
|
|
13
13
|
import { queuePersonaTopicRating, type PersonaTopicContext, type PersonaTopicOptions } from "./persona-topics.js";
|
|
14
|
+
import { getRoomVisibleMessages, queueRoomHumanExtraction } from "./room-extraction.js";
|
|
14
15
|
import { queuePersonMigration } from "./person-migration.js";
|
|
15
16
|
import { buildRewriteScanPrompt, type RewriteItemType } from "../../prompts/ceremony/index.js";
|
|
16
17
|
import { buildReflectionCriticPrompt } from "../../prompts/reflection/index.js";
|
|
@@ -139,7 +140,10 @@ function queueExposurePhase(personaId: string, state: StateManager, options?: Ex
|
|
|
139
140
|
messages_analyze: unextractedPeople,
|
|
140
141
|
extraction_flag: "p",
|
|
141
142
|
};
|
|
142
|
-
|
|
143
|
+
const personScanOptions = persona.pending_update
|
|
144
|
+
? { ...options, reflection_progress: 1 }
|
|
145
|
+
: options;
|
|
146
|
+
queuePersonScan(context, state, personScanOptions);
|
|
143
147
|
}
|
|
144
148
|
|
|
145
149
|
const totalUnextracted = unextractedFacts.length + unextractedTopics.length + unextractedPeople.length;
|
|
@@ -206,20 +210,26 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
|
|
|
206
210
|
const rooms = state.getRoomList();
|
|
207
211
|
for (const room of rooms) {
|
|
208
212
|
if (room.mode === RoomMode.ChooseYourPath) continue;
|
|
213
|
+
|
|
214
|
+
// Human extraction (t/p) — straggler scan for messages that never hit the
|
|
215
|
+
// per-send threshold in checkAndQueueRoomExtraction
|
|
216
|
+
queueRoomHumanExtraction(state, room.id, 2);
|
|
217
|
+
|
|
218
|
+
// Persona topic rating — uses getRoomVisibleMessages so FFA rooms get all
|
|
219
|
+
// messages, not just the active path chain
|
|
220
|
+
const allRoomMessages = getRoomVisibleMessages(state, room.id);
|
|
209
221
|
for (const personaId of room.persona_ids) {
|
|
210
222
|
const shortId = personaId.slice(0, 8);
|
|
211
223
|
const unprocessedRaw = state.getRoomUnextractedMessagesForPersona(room.id, shortId);
|
|
212
224
|
if (unprocessedRaw.length === 0) continue;
|
|
213
225
|
const personaForRoom = state.persona_getById(personaId);
|
|
214
226
|
if (!personaForRoom) continue;
|
|
215
|
-
const
|
|
216
|
-
const processedIds = new Set(allRoomMessagesRaw.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
|
|
217
|
-
const allNormalized = normalizeRoomMessages(allRoomMessagesRaw, state);
|
|
227
|
+
const processedIds = new Set(allRoomMessages.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
|
|
218
228
|
const unprocessedNormalized = normalizeRoomMessages(unprocessedRaw, state);
|
|
219
229
|
const personaTopicContext: PersonaTopicContext = {
|
|
220
230
|
personaId,
|
|
221
231
|
personaDisplayName: personaForRoom.display_name,
|
|
222
|
-
messages_context:
|
|
232
|
+
messages_context: allRoomMessages.filter(m => processedIds.has(m.id)),
|
|
223
233
|
messages_analyze: unprocessedNormalized,
|
|
224
234
|
topics: personaForRoom.topics,
|
|
225
235
|
};
|
|
@@ -445,6 +455,35 @@ const REWRITE_DESCRIPTION_THRESHOLD = 750;
|
|
|
445
455
|
* Phase 2 items enqueue at Normal priority, naturally processing before more
|
|
446
456
|
* Low-priority Phase 1 scans.
|
|
447
457
|
*/
|
|
458
|
+
/**
|
|
459
|
+
* Forces an unconditional, threshold-bypassing Person scan on Apply/Dismiss.
|
|
460
|
+
* Cannot be replaced by checkAndQueueHumanExtraction — that function gates on
|
|
461
|
+
* MIN(10, people_count) and would silently skip messages if the threshold isn't
|
|
462
|
+
* met, leaving reflection-era noise unprocessed and ungated.
|
|
463
|
+
*/
|
|
464
|
+
export function queueReflectionDrain(personaId: string, state: StateManager): void {
|
|
465
|
+
const persona = state.persona_getById(personaId);
|
|
466
|
+
if (!persona) return;
|
|
467
|
+
|
|
468
|
+
const allMessages = state.messages_get(personaId);
|
|
469
|
+
const unextractedPeople = state.messages_getUnextracted(personaId, "p");
|
|
470
|
+
|
|
471
|
+
if (unextractedPeople.length === 0) {
|
|
472
|
+
console.log(`[reflection:drain] No unextracted messages for ${persona.display_name} — drain complete`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const context: ExtractionContext = {
|
|
477
|
+
personaId,
|
|
478
|
+
personaDisplayName: persona.display_name,
|
|
479
|
+
messages_context: allMessages.filter(m => m.p === true),
|
|
480
|
+
messages_analyze: unextractedPeople,
|
|
481
|
+
extraction_flag: "p",
|
|
482
|
+
};
|
|
483
|
+
queuePersonScan(context, state, { reflection_progress: 1 });
|
|
484
|
+
console.log(`[reflection:drain] Queued Person scan for ${persona.display_name} (${unextractedPeople.length} messages) — clears on completion`);
|
|
485
|
+
}
|
|
486
|
+
|
|
448
487
|
export function queueRewritePhase(state: StateManager): void {
|
|
449
488
|
const human = state.getHuman();
|
|
450
489
|
const rewriteModel = human.settings?.rewrite_model;
|
|
@@ -12,7 +12,6 @@ import {
|
|
|
12
12
|
type TopicScanCandidate,
|
|
13
13
|
type ItemMatchResult,
|
|
14
14
|
type ParticipantContext,
|
|
15
|
-
type PersonaEntitySnapshot,
|
|
16
15
|
} from "../../prompts/human/index.js";
|
|
17
16
|
import { buildValidatePrompt } from "../../prompts/ceremony/dedup.js";
|
|
18
17
|
import { normalizeRoomMessages } from "../handlers/utils.js";
|
|
@@ -67,6 +66,8 @@ export interface ExtractionContext {
|
|
|
67
66
|
export interface ExtractionOptions {
|
|
68
67
|
/** Ceremony phase number (1=Dedup, 2=Expose) */
|
|
69
68
|
ceremony_progress?: number;
|
|
69
|
+
/** Set to 1 on scans queued while there is a Pending Reflection. */
|
|
70
|
+
reflection_progress?: number;
|
|
70
71
|
/** Override model for extraction LLM calls */
|
|
71
72
|
extraction_model?: string;
|
|
72
73
|
/**
|
|
@@ -98,21 +99,23 @@ function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions
|
|
|
98
99
|
export function queueFactFind(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
|
|
99
100
|
const human = state.getHuman();
|
|
100
101
|
const extractionModel = options?.extraction_model;
|
|
101
|
-
const missing_fact_names = human.facts
|
|
102
|
-
.filter(f => !f.description || f.description === "")
|
|
103
|
-
.map(f => f.name)
|
|
104
|
-
.filter(name => BUILT_IN_FACT_NAMES.has(name));
|
|
105
|
-
|
|
106
|
-
if (missing_fact_names.length === 0) return 0;
|
|
107
102
|
|
|
108
103
|
const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
|
|
109
104
|
|
|
110
|
-
//
|
|
111
|
-
//
|
|
105
|
+
// Always pre-mark f — even when all facts are already known.
|
|
106
|
+
// Once we stop scanning for facts, messages must still be marked so the
|
|
107
|
+
// ceremony doesn't treat them as perpetually unprocessed.
|
|
112
108
|
for (const chunk of chunks) {
|
|
113
109
|
state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "f");
|
|
114
110
|
}
|
|
115
111
|
|
|
112
|
+
const missing_fact_names = human.facts
|
|
113
|
+
.filter(f => !f.description || f.description === "")
|
|
114
|
+
.map(f => f.name)
|
|
115
|
+
.filter(name => BUILT_IN_FACT_NAMES.has(name));
|
|
116
|
+
|
|
117
|
+
if (missing_fact_names.length === 0) return 0;
|
|
118
|
+
|
|
116
119
|
for (const chunk of chunks) {
|
|
117
120
|
const prompt = buildFactFindPrompt({
|
|
118
121
|
persona_name: chunk.personaDisplayName,
|
|
@@ -584,18 +587,6 @@ export function queuePersonUpdate(
|
|
|
584
587
|
|
|
585
588
|
const primaryPersonaIdForUpdate = context.personaId.split("|")[0];
|
|
586
589
|
|
|
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
|
-
|
|
599
590
|
for (const chunk of chunks) {
|
|
600
591
|
const prompt = buildPersonUpdatePrompt({
|
|
601
592
|
existing_item: existingItem,
|
|
@@ -607,7 +598,6 @@ export function queuePersonUpdate(
|
|
|
607
598
|
persona_name: chunk.personaDisplayName,
|
|
608
599
|
participant_context: buildParticipantContext(primaryPersonaIdForUpdate, state),
|
|
609
600
|
known_identifier_types: userIdentifierTypes,
|
|
610
|
-
persona_entity: personaEntitySnapshot,
|
|
611
601
|
});
|
|
612
602
|
|
|
613
603
|
state.queue_enqueue({
|
|
@@ -73,7 +73,8 @@ function queueRoomTopicScan(
|
|
|
73
73
|
messages_context: Message[],
|
|
74
74
|
messages_analyze: Message[],
|
|
75
75
|
state: StateManager,
|
|
76
|
-
participantContext: ParticipantContext
|
|
76
|
+
participantContext: ParticipantContext,
|
|
77
|
+
ceremony_progress?: number
|
|
77
78
|
): void {
|
|
78
79
|
const context: HumanExtractionContext = {
|
|
79
80
|
personaId: roomId,
|
|
@@ -105,6 +106,7 @@ function queueRoomTopicScan(
|
|
|
105
106
|
personaId: (state.getRoom(roomId)?.persona_ids ?? []).join("|"),
|
|
106
107
|
personaDisplayName: roomDisplayName,
|
|
107
108
|
message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
|
|
109
|
+
...(ceremony_progress !== undefined ? { ceremony_progress } : {}),
|
|
108
110
|
},
|
|
109
111
|
});
|
|
110
112
|
}
|
|
@@ -115,7 +117,8 @@ function queueRoomPersonScan(
|
|
|
115
117
|
roomDisplayName: string,
|
|
116
118
|
messages_context: Message[],
|
|
117
119
|
messages_analyze: Message[],
|
|
118
|
-
state: StateManager
|
|
120
|
+
state: StateManager,
|
|
121
|
+
ceremony_progress?: number
|
|
119
122
|
): void {
|
|
120
123
|
const context: HumanExtractionContext = {
|
|
121
124
|
personaId: roomId,
|
|
@@ -146,6 +149,7 @@ function queueRoomPersonScan(
|
|
|
146
149
|
personaId: (state.getRoom(roomId)?.persona_ids ?? []).join("|"),
|
|
147
150
|
personaDisplayName: roomDisplayName,
|
|
148
151
|
message_ids_to_mark: chunk.messages_analyze.map(m => m.id),
|
|
152
|
+
...(ceremony_progress !== undefined ? { ceremony_progress } : {}),
|
|
149
153
|
},
|
|
150
154
|
});
|
|
151
155
|
}
|
|
@@ -241,6 +245,40 @@ export function checkAndQueueRoomExtraction(state: StateManager, roomId: string)
|
|
|
241
245
|
console.log(`[checkAndQueueRoomExtraction] Auto-triggered extraction for room ${roomDisplayName} (threshold: ${threshold})`);
|
|
242
246
|
}
|
|
243
247
|
|
|
248
|
+
export function queueRoomHumanExtraction(state: StateManager, roomId: string, ceremony_progress?: number): void {
|
|
249
|
+
const room = state.getRoom(roomId);
|
|
250
|
+
if (!room || room.mode === RoomMode.ChooseYourPath) return;
|
|
251
|
+
|
|
252
|
+
const allVisible = getRoomVisibleMessages(state, roomId);
|
|
253
|
+
if (allVisible.length === 0) return;
|
|
254
|
+
|
|
255
|
+
const participantContext = buildRoomParticipantContext(roomId, state);
|
|
256
|
+
const roomDisplayName = room.display_name;
|
|
257
|
+
|
|
258
|
+
const unextractedT = allVisible.filter(m => !m.t);
|
|
259
|
+
const unextractedP = allVisible.filter(m => !m.p);
|
|
260
|
+
|
|
261
|
+
if (unextractedT.length === 0 && unextractedP.length === 0) return;
|
|
262
|
+
|
|
263
|
+
if (unextractedT.length > 0) {
|
|
264
|
+
const analyzeStart = unextractedT[0].timestamp;
|
|
265
|
+
const messages_contextT = allVisible.filter(
|
|
266
|
+
m => m.t === true && new Date(m.timestamp).getTime() < new Date(analyzeStart).getTime()
|
|
267
|
+
);
|
|
268
|
+
queueRoomTopicScan(roomId, roomDisplayName, messages_contextT, unextractedT, state, participantContext, ceremony_progress);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (unextractedP.length > 0) {
|
|
272
|
+
const analyzeStart = unextractedP[0].timestamp;
|
|
273
|
+
const messages_contextP = allVisible.filter(
|
|
274
|
+
m => m.p === true && new Date(m.timestamp).getTime() < new Date(analyzeStart).getTime()
|
|
275
|
+
);
|
|
276
|
+
queueRoomPersonScan(roomId, roomDisplayName, messages_contextP, unextractedP, state, ceremony_progress);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
console.log(`[queueRoomHumanExtraction] Queued human extraction for room "${roomDisplayName}" (t:${unextractedT.length}, p:${unextractedP.length})`);
|
|
280
|
+
}
|
|
281
|
+
|
|
244
282
|
export function queueRoomCapture(state: StateManager, roomId: string): void {
|
|
245
283
|
const room = state.getRoom(roomId);
|
|
246
284
|
if (!room) return;
|
package/src/core/processor.ts
CHANGED
|
@@ -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, queueTargetedPersonUpdate, queueTargetedTopicUpdate } from "./orchestrators/index.js";
|
|
42
|
+
import { shouldStartCeremony, startCeremony, handleCeremonyProgress, queueReflectionDrain, 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
|
|
|
@@ -1755,6 +1755,43 @@ const toolNextSteps = new Set([
|
|
|
1755
1755
|
if (ok) this.interface.onPersonaUpdated?.(personaId);
|
|
1756
1756
|
}
|
|
1757
1757
|
|
|
1758
|
+
async finalizeReflection(
|
|
1759
|
+
personaId: string,
|
|
1760
|
+
action: "apply" | "dismiss",
|
|
1761
|
+
identity?: { short_description?: string; long_description: string; traits: NonNullable<PersonaEntity["pending_update"]>["traits"]; topics: NonNullable<PersonaEntity["pending_update"]>["topics"] }
|
|
1762
|
+
): Promise<void> {
|
|
1763
|
+
const persona = this.stateManager.persona_getById(personaId);
|
|
1764
|
+
if (!persona) return;
|
|
1765
|
+
|
|
1766
|
+
const source = identity ?? (persona.pending_update ? {
|
|
1767
|
+
short_description: persona.pending_update.short_description,
|
|
1768
|
+
long_description: persona.pending_update.long_description,
|
|
1769
|
+
traits: persona.pending_update.traits,
|
|
1770
|
+
topics: persona.pending_update.topics,
|
|
1771
|
+
} : null);
|
|
1772
|
+
|
|
1773
|
+
const updates: Partial<PersonaEntity> = { pending_update: undefined };
|
|
1774
|
+
|
|
1775
|
+
if (action === "apply" && source) {
|
|
1776
|
+
updates.short_description = source.short_description;
|
|
1777
|
+
updates.long_description = source.long_description;
|
|
1778
|
+
updates.traits = source.traits.map(t => ({
|
|
1779
|
+
...t,
|
|
1780
|
+
id: t.id?.startsWith("pending-") ? crypto.randomUUID() : t.id,
|
|
1781
|
+
}));
|
|
1782
|
+
updates.topics = source.topics.map(t => ({
|
|
1783
|
+
...t,
|
|
1784
|
+
id: t.id?.startsWith("pending-") ? crypto.randomUUID() : t.id,
|
|
1785
|
+
}));
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
const ok = await updatePersona(this.stateManager, personaId, updates);
|
|
1789
|
+
if (ok) {
|
|
1790
|
+
queueReflectionDrain(personaId, this.stateManager);
|
|
1791
|
+
this.interface.onPersonaUpdated?.(personaId);
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1758
1795
|
async updateRoom(roomId: string, updates: Partial<RoomEntity>): Promise<void> {
|
|
1759
1796
|
const ok = this.stateManager.updateRoom(roomId, updates);
|
|
1760
1797
|
if (ok) this.interface.onRoomUpdated?.(roomId);
|
|
@@ -47,24 +47,48 @@ function capTopicsAndPeople<T extends { id: string }, P extends { id: string }>(
|
|
|
47
47
|
// EMBEDDING-BASED RELEVANCE SELECTION
|
|
48
48
|
// =============================================================================
|
|
49
49
|
|
|
50
|
+
const RECENT_MESSAGES_FOR_CONTEXT = 5;
|
|
51
|
+
|
|
52
|
+
async function buildQueryVectors(queries: string[]): Promise<number[][]> {
|
|
53
|
+
const embeddingService = getEmbeddingService();
|
|
54
|
+
return Promise.all(queries.map(q => embeddingService.embed(q)));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function unionTopK<T extends { id: string }>(
|
|
58
|
+
candidates: T[],
|
|
59
|
+
queryVectors: number[][],
|
|
60
|
+
limit: number
|
|
61
|
+
): T[] {
|
|
62
|
+
const best = new Map<string, { item: T; similarity: number }>();
|
|
63
|
+
for (const qv of queryVectors) {
|
|
64
|
+
for (const { item, similarity } of findTopK(qv, candidates, limit)) {
|
|
65
|
+
const existing = best.get(item.id);
|
|
66
|
+
if (!existing || similarity > existing.similarity) {
|
|
67
|
+
best.set(item.id, { item, similarity });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return Array.from(best.values())
|
|
72
|
+
.filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
|
|
73
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
74
|
+
.slice(0, limit)
|
|
75
|
+
.map(({ item }) => item);
|
|
76
|
+
}
|
|
77
|
+
|
|
50
78
|
async function selectRelevantItems<T extends { id: string; embedding?: number[] }>(
|
|
51
79
|
items: T[],
|
|
52
80
|
limit: number,
|
|
53
|
-
|
|
81
|
+
queries: string[]
|
|
54
82
|
): Promise<T[]> {
|
|
55
83
|
if (items.length === 0) return [];
|
|
56
84
|
|
|
57
85
|
const withEmbeddings = items.filter((i) => i.embedding?.length);
|
|
86
|
+
const activeQueries = queries.filter(Boolean);
|
|
58
87
|
|
|
59
|
-
if (
|
|
88
|
+
if (activeQueries.length > 0 && withEmbeddings.length > 0) {
|
|
60
89
|
try {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const results = findTopK(queryVector, withEmbeddings, limit);
|
|
64
|
-
const relevant = results
|
|
65
|
-
.filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
|
|
66
|
-
.map(({ item }) => item);
|
|
67
|
-
|
|
90
|
+
const queryVectors = await buildQueryVectors(activeQueries);
|
|
91
|
+
const relevant = unionTopK(withEmbeddings, queryVectors, limit);
|
|
68
92
|
if (relevant.length > 0) return relevant;
|
|
69
93
|
} catch (err) {
|
|
70
94
|
console.warn("[filterHumanDataByVisibility] Embedding search failed:", err);
|
|
@@ -80,19 +104,15 @@ async function selectRelevantItems<T extends { id: string; embedding?: number[]
|
|
|
80
104
|
.slice(0, limit);
|
|
81
105
|
}
|
|
82
106
|
|
|
83
|
-
async function selectRelevantQuotes(quotes: Quote[],
|
|
107
|
+
async function selectRelevantQuotes(quotes: Quote[], queries: string[]): Promise<Quote[]> {
|
|
84
108
|
if (quotes.length === 0) return [];
|
|
85
109
|
const withEmbeddings = quotes.filter((q) => q.embedding?.length);
|
|
110
|
+
const activeQueries = queries.filter(Boolean);
|
|
86
111
|
|
|
87
|
-
if (
|
|
112
|
+
if (activeQueries.length > 0 && withEmbeddings.length > 0) {
|
|
88
113
|
try {
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const results = findTopK(queryVector, withEmbeddings, QUOTE_LIMIT);
|
|
92
|
-
const relevant = results
|
|
93
|
-
.filter(({ similarity }) => similarity >= SIMILARITY_THRESHOLD)
|
|
94
|
-
.map(({ item }) => item);
|
|
95
|
-
|
|
114
|
+
const queryVectors = await buildQueryVectors(activeQueries);
|
|
115
|
+
const relevant = unionTopK(withEmbeddings, queryVectors, QUOTE_LIMIT);
|
|
96
116
|
if (relevant.length > 0) return relevant;
|
|
97
117
|
} catch (err) {
|
|
98
118
|
console.warn("[filterHumanDataByVisibility] Embedding search failed:", err);
|
|
@@ -110,16 +130,16 @@ async function selectRelevantQuotes(quotes: Quote[], currentMessage?: string): P
|
|
|
110
130
|
export async function filterHumanDataByVisibility(
|
|
111
131
|
human: HumanEntity,
|
|
112
132
|
persona: PersonaEntity,
|
|
113
|
-
|
|
133
|
+
queries: string[]
|
|
114
134
|
): Promise<ResponsePromptData["human"]> {
|
|
115
135
|
const DEFAULT_GROUP = "General";
|
|
116
136
|
|
|
117
137
|
if (persona.id === "ei") {
|
|
118
138
|
const [facts, rawTopics, rawPeople, quotes] = await Promise.all([
|
|
119
|
-
selectRelevantItems(human.facts, DATA_ITEM_LIMIT,
|
|
120
|
-
selectRelevantItems(human.topics, DATA_ITEM_LIMIT,
|
|
121
|
-
selectRelevantItems(human.people, DATA_ITEM_LIMIT,
|
|
122
|
-
selectRelevantQuotes(human.quotes ?? [],
|
|
139
|
+
selectRelevantItems(human.facts, DATA_ITEM_LIMIT, queries),
|
|
140
|
+
selectRelevantItems(human.topics, DATA_ITEM_LIMIT, queries),
|
|
141
|
+
selectRelevantItems(human.people, DATA_ITEM_LIMIT, queries),
|
|
142
|
+
selectRelevantQuotes(human.quotes ?? [], queries),
|
|
123
143
|
]);
|
|
124
144
|
const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
|
|
125
145
|
const humanName =
|
|
@@ -157,10 +177,10 @@ export async function filterHumanDataByVisibility(
|
|
|
157
177
|
});
|
|
158
178
|
|
|
159
179
|
const [facts, rawTopics, rawPeople, quotes] = await Promise.all([
|
|
160
|
-
selectRelevantItems(filterByGroup(human.facts), DATA_ITEM_LIMIT,
|
|
161
|
-
selectRelevantItems(filterByGroup(human.topics), DATA_ITEM_LIMIT,
|
|
162
|
-
selectRelevantItems(filterByGroup(human.people), DATA_ITEM_LIMIT,
|
|
163
|
-
selectRelevantQuotes(groupFilteredQuotes,
|
|
180
|
+
selectRelevantItems(filterByGroup(human.facts), DATA_ITEM_LIMIT, queries),
|
|
181
|
+
selectRelevantItems(filterByGroup(human.topics), DATA_ITEM_LIMIT, queries),
|
|
182
|
+
selectRelevantItems(filterByGroup(human.people), DATA_ITEM_LIMIT, queries),
|
|
183
|
+
selectRelevantQuotes(groupFilteredQuotes, queries),
|
|
164
184
|
]);
|
|
165
185
|
const { topics, people } = capTopicsAndPeople(rawTopics, rawPeople);
|
|
166
186
|
|
|
@@ -233,14 +253,25 @@ export async function buildResponsePromptData(
|
|
|
233
253
|
tools?: import("./types.js").ToolDefinition[]
|
|
234
254
|
): Promise<ResponsePromptData> {
|
|
235
255
|
const human = sm.getHuman();
|
|
236
|
-
const filteredHuman = await filterHumanDataByVisibility(human, persona, currentMessage);
|
|
237
|
-
const visiblePersonas = getVisiblePersonas(sm, persona);
|
|
238
256
|
const messages = sm.messages_get(persona.id);
|
|
239
257
|
const previousMessage = messages.length >= 2 ? messages[messages.length - 2] : null;
|
|
240
258
|
const delayMs = previousMessage
|
|
241
259
|
? Date.now() - new Date(previousMessage.timestamp).getTime()
|
|
242
260
|
: 0;
|
|
243
261
|
|
|
262
|
+
const recentMessageContents = messages
|
|
263
|
+
.slice(-RECENT_MESSAGES_FOR_CONTEXT - 1, -1)
|
|
264
|
+
.map(m => getMessageContent(m))
|
|
265
|
+
.filter(Boolean);
|
|
266
|
+
|
|
267
|
+
const queries = [
|
|
268
|
+
...(currentMessage ? [currentMessage] : []),
|
|
269
|
+
...recentMessageContents,
|
|
270
|
+
];
|
|
271
|
+
|
|
272
|
+
const filteredHuman = await filterHumanDataByVisibility(human, persona, queries);
|
|
273
|
+
const visiblePersonas = getVisiblePersonas(sm, persona);
|
|
274
|
+
|
|
244
275
|
const alwaysMessages = sm.messages_getAlways(persona.id);
|
|
245
276
|
const temporalAnchors = alwaysMessages.map(m => ({
|
|
246
277
|
role: m.role === "human" ? "human" as const : "system" as const,
|
|
@@ -313,7 +344,7 @@ export async function buildRoomResponsePromptData(
|
|
|
313
344
|
const lastMessage = sourceMessages[sourceMessages.length - 1];
|
|
314
345
|
const currentMessage = lastMessage ? getMessageContent(lastMessage) : undefined;
|
|
315
346
|
|
|
316
|
-
const filteredHuman = await filterHumanDataByVisibility(human, respondingPersona, currentMessage);
|
|
347
|
+
const filteredHuman = await filterHumanDataByVisibility(human, respondingPersona, currentMessage ? [currentMessage] : []);
|
|
317
348
|
|
|
318
349
|
const history = normalizeRoomMessages(sourceMessages, sm);
|
|
319
350
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { PromptOutput, ParticipantContext
|
|
1
|
+
import type { PromptOutput, ParticipantContext } from "./types.js";
|
|
2
2
|
import type { Person, Message } from "../../core/types.js";
|
|
3
3
|
import { formatMessagesAsPlaceholders } from "../message-utils.js";
|
|
4
4
|
|
|
@@ -12,7 +12,6 @@ export interface PersonUpdatePromptData {
|
|
|
12
12
|
persona_name: string;
|
|
13
13
|
participant_context?: ParticipantContext;
|
|
14
14
|
known_identifier_types?: string[];
|
|
15
|
-
persona_entity?: PersonaEntitySnapshot;
|
|
16
15
|
}
|
|
17
16
|
|
|
18
17
|
function participantContextSection(ctx: ParticipantContext | undefined): string {
|
|
@@ -121,38 +120,22 @@ Detail you add should:
|
|
|
121
120
|
**ABSOLUTELY VITAL**: Do **NOT** embellish. Record only what the user actually said or demonstrated.`;
|
|
122
121
|
|
|
123
122
|
} else if (isEiPersona) {
|
|
124
|
-
|
|
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.
|
|
123
|
+
descriptionSection = `This record is the HUMAN USER's **observed experience** of ${personName} over time. Think of it as field notes from someone who has been talking with this Persona across many conversations.
|
|
142
124
|
|
|
143
125
|
## Your job: add, never truncate
|
|
144
126
|
|
|
145
127
|
The description is allowed to grow. **Never remove or summarize away existing content.**
|
|
146
128
|
|
|
147
129
|
Add anything from the Most Recent Messages that:
|
|
148
|
-
-
|
|
149
|
-
-
|
|
150
|
-
-
|
|
130
|
+
- Records a specific thing ${personName} said, did, or demonstrated in this conversation
|
|
131
|
+
- Captures how the HUMAN USER experienced or related to ${personName} in this specific exchange
|
|
132
|
+
- Notes behavior that stands out — expected or surprising — based on what the existing log already shows
|
|
151
133
|
|
|
152
134
|
**Do NOT:**
|
|
153
135
|
- Synthesize the existing description down to fewer sentences
|
|
154
136
|
- Replace specific observations with vague summaries
|
|
155
137
|
- Discard detail to "keep it brief" — brevity is wrong here
|
|
138
|
+
- Add anything that isn't directly observed in the Most Recent Messages
|
|
156
139
|
|
|
157
140
|
If the new messages add nothing meaningful, return \`{}\`. Otherwise, return the **full updated description** — existing content preserved, new observations woven in.`;
|
|
158
141
|
|
|
@@ -58,7 +58,9 @@ Rules:
|
|
|
58
58
|
- Never invent observations not supported by the log
|
|
59
59
|
- Preserve traits and topics the log confirms — don't remove them
|
|
60
60
|
- If the log shows no evidence on a trait, leave it unchanged
|
|
61
|
-
- updated_identity must be complete and self-contained — not a diff
|
|
61
|
+
- updated_identity must be complete and self-contained — not a diff
|
|
62
|
+
- long_description is a character sketch, not a behavior log: capture who the persona IS, not what they did. Target 500–800 characters. If the current long_description exceeds that, distill it — remove detail that is already captured by traits or topics
|
|
63
|
+
- If the log shows a recurring behavioral pattern not yet in traits, add it as a trait and remove that detail from long_description rather than keeping it in both places`;
|
|
62
64
|
|
|
63
65
|
const user = `## Current Identity
|
|
64
66
|
|
|
@@ -233,7 +233,7 @@ export const personaCommand: Command = {
|
|
|
233
233
|
}
|
|
234
234
|
|
|
235
235
|
if (reviewResult.content === null) {
|
|
236
|
-
await ctx.ei.
|
|
236
|
+
await ctx.ei.finalizeReflection(personaId, "dismiss");
|
|
237
237
|
ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
|
|
238
238
|
return;
|
|
239
239
|
}
|
|
@@ -267,17 +267,16 @@ export const personaCommand: Command = {
|
|
|
267
267
|
return;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
await ctx.ei.
|
|
270
|
+
await ctx.ei.finalizeReflection(personaId, "dismiss");
|
|
271
271
|
ctx.showNotification(`Dismissed pending changes for ${persona.display_name}`, "info");
|
|
272
272
|
return;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
-
await ctx.ei.
|
|
275
|
+
await ctx.ei.finalizeReflection(personaId, "apply", {
|
|
276
276
|
long_description: previewParsed.long_description,
|
|
277
277
|
short_description: previewParsed.short_description,
|
|
278
278
|
traits: previewParsed.traits,
|
|
279
279
|
topics: previewParsed.topics,
|
|
280
|
-
pending_update: undefined,
|
|
281
280
|
});
|
|
282
281
|
ctx.showNotification(`Applied changes to ${persona.display_name}`, "info");
|
|
283
282
|
return;
|
|
@@ -327,13 +327,7 @@ export const reflectCommand: Command = {
|
|
|
327
327
|
topics: persona.pending_update!.topics,
|
|
328
328
|
};
|
|
329
329
|
|
|
330
|
-
await ctx.ei.
|
|
331
|
-
long_description: source.long_description,
|
|
332
|
-
short_description: source.short_description,
|
|
333
|
-
traits: source.traits,
|
|
334
|
-
topics: source.topics,
|
|
335
|
-
pending_update: undefined,
|
|
336
|
-
});
|
|
330
|
+
await ctx.ei.finalizeReflection(personaId, "apply", source);
|
|
337
331
|
|
|
338
332
|
if (fs.existsSync(folderPath)) {
|
|
339
333
|
fs.rmSync(folderPath, { recursive: true, force: true });
|
|
@@ -363,7 +357,7 @@ export const reflectCommand: Command = {
|
|
|
363
357
|
return;
|
|
364
358
|
}
|
|
365
359
|
|
|
366
|
-
await ctx.ei.
|
|
360
|
+
await ctx.ei.finalizeReflection(personaId, "dismiss");
|
|
367
361
|
const folderPath = getReflectFolder(persona);
|
|
368
362
|
if (fs.existsSync(folderPath)) {
|
|
369
363
|
fs.rmSync(folderPath, { recursive: true, force: true });
|
package/tui/src/context/ei.tsx
CHANGED
|
@@ -80,6 +80,7 @@ export interface EiContextValue {
|
|
|
80
80
|
deletePersona: (personaId: string) => Promise<void>;
|
|
81
81
|
setContextBoundary: (personaId: string, timestamp: string | null) => Promise<void>;
|
|
82
82
|
updatePersona: (personaId: string, updates: Partial<PersonaEntity>) => Promise<void>;
|
|
83
|
+
finalizeReflection: (personaId: string, action: "apply" | "dismiss", identity?: { short_description?: string; long_description: string; traits: NonNullable<PersonaEntity["pending_update"]>["traits"]; topics: NonNullable<PersonaEntity["pending_update"]>["topics"] }) => Promise<void>;
|
|
83
84
|
getPersona: (personaId: string) => Promise<PersonaEntity | null>;
|
|
84
85
|
resolvePersonaName: (nameOrAlias: string) => Promise<string | null>;
|
|
85
86
|
getHuman: () => Promise<HumanEntity>;
|
|
@@ -360,6 +361,16 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
360
361
|
await refreshPersonas();
|
|
361
362
|
};
|
|
362
363
|
|
|
364
|
+
const finalizeReflection = async (
|
|
365
|
+
personaId: string,
|
|
366
|
+
action: "apply" | "dismiss",
|
|
367
|
+
identity?: { short_description?: string; long_description: string; traits: NonNullable<PersonaEntity["pending_update"]>["traits"]; topics: NonNullable<PersonaEntity["pending_update"]>["topics"] }
|
|
368
|
+
) => {
|
|
369
|
+
if (!processor) return;
|
|
370
|
+
await processor.finalizeReflection(personaId, action, identity);
|
|
371
|
+
await refreshPersonas();
|
|
372
|
+
};
|
|
373
|
+
|
|
363
374
|
const getPersona = async (personaId: string) => {
|
|
364
375
|
if (!processor) return null;
|
|
365
376
|
return processor.getPersona(personaId);
|
|
@@ -937,6 +948,7 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
937
948
|
deletePersona,
|
|
938
949
|
setContextBoundary,
|
|
939
950
|
updatePersona,
|
|
951
|
+
finalizeReflection,
|
|
940
952
|
getPersona,
|
|
941
953
|
resolvePersonaName,
|
|
942
954
|
getHuman,
|