ei-tui 0.9.2 → 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/heartbeat-manager.ts +1 -1
- package/src/core/orchestrators/ceremony.ts +11 -4
- package/src/core/orchestrators/human-extraction.ts +10 -22
- package/src/core/orchestrators/room-extraction.ts +40 -2
- 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/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
|
|
|
@@ -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))
|
|
@@ -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";
|
|
@@ -209,20 +210,26 @@ export function handleCeremonyProgress(state: StateManager, lastPhase: number):
|
|
|
209
210
|
const rooms = state.getRoomList();
|
|
210
211
|
for (const room of rooms) {
|
|
211
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);
|
|
212
221
|
for (const personaId of room.persona_ids) {
|
|
213
222
|
const shortId = personaId.slice(0, 8);
|
|
214
223
|
const unprocessedRaw = state.getRoomUnextractedMessagesForPersona(room.id, shortId);
|
|
215
224
|
if (unprocessedRaw.length === 0) continue;
|
|
216
225
|
const personaForRoom = state.persona_getById(personaId);
|
|
217
226
|
if (!personaForRoom) continue;
|
|
218
|
-
const
|
|
219
|
-
const processedIds = new Set(allRoomMessagesRaw.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
|
|
220
|
-
const allNormalized = normalizeRoomMessages(allRoomMessagesRaw, state);
|
|
227
|
+
const processedIds = new Set(allRoomMessages.filter(m => !!m.persona_extracted?.[shortId]).map(m => m.id));
|
|
221
228
|
const unprocessedNormalized = normalizeRoomMessages(unprocessedRaw, state);
|
|
222
229
|
const personaTopicContext: PersonaTopicContext = {
|
|
223
230
|
personaId,
|
|
224
231
|
personaDisplayName: personaForRoom.display_name,
|
|
225
|
-
messages_context:
|
|
232
|
+
messages_context: allRoomMessages.filter(m => processedIds.has(m.id)),
|
|
226
233
|
messages_analyze: unprocessedNormalized,
|
|
227
234
|
topics: personaForRoom.topics,
|
|
228
235
|
};
|
|
@@ -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";
|
|
@@ -100,21 +99,23 @@ function getExtractionMaxTokens(state: StateManager, options?: ExtractionOptions
|
|
|
100
99
|
export function queueFactFind(context: ExtractionContext, state: StateManager, options?: ExtractionOptions): number {
|
|
101
100
|
const human = state.getHuman();
|
|
102
101
|
const extractionModel = options?.extraction_model;
|
|
103
|
-
const missing_fact_names = human.facts
|
|
104
|
-
.filter(f => !f.description || f.description === "")
|
|
105
|
-
.map(f => f.name)
|
|
106
|
-
.filter(name => BUILT_IN_FACT_NAMES.has(name));
|
|
107
|
-
|
|
108
|
-
if (missing_fact_names.length === 0) return 0;
|
|
109
102
|
|
|
110
103
|
const { chunks } = chunkExtractionContext(context, getExtractionMaxTokens(state, options));
|
|
111
104
|
|
|
112
|
-
//
|
|
113
|
-
//
|
|
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.
|
|
114
108
|
for (const chunk of chunks) {
|
|
115
109
|
state.messages_markExtracted(chunk.personaId, chunk.messages_analyze.map(m => m.id), "f");
|
|
116
110
|
}
|
|
117
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
|
+
|
|
118
119
|
for (const chunk of chunks) {
|
|
119
120
|
const prompt = buildFactFindPrompt({
|
|
120
121
|
persona_name: chunk.personaDisplayName,
|
|
@@ -586,18 +587,6 @@ export function queuePersonUpdate(
|
|
|
586
587
|
|
|
587
588
|
const primaryPersonaIdForUpdate = context.personaId.split("|")[0];
|
|
588
589
|
|
|
589
|
-
const linkedPersonaId = !isNewItem
|
|
590
|
-
? (existingItem?.identifiers ?? []).find(i => i.type.toLowerCase() === 'ei persona')?.value
|
|
591
|
-
: undefined;
|
|
592
|
-
const linkedPersonaEntity = linkedPersonaId ? state.persona_getById(linkedPersonaId) : undefined;
|
|
593
|
-
const personaEntitySnapshot: PersonaEntitySnapshot | undefined = linkedPersonaEntity
|
|
594
|
-
? {
|
|
595
|
-
long_description: linkedPersonaEntity.long_description ?? '',
|
|
596
|
-
traits: (linkedPersonaEntity.traits ?? []).map(t => ({ name: t.name, description: t.description })),
|
|
597
|
-
topics: (linkedPersonaEntity.topics ?? []).map(t => ({ name: t.name, perspective: t.perspective })),
|
|
598
|
-
}
|
|
599
|
-
: undefined;
|
|
600
|
-
|
|
601
590
|
for (const chunk of chunks) {
|
|
602
591
|
const prompt = buildPersonUpdatePrompt({
|
|
603
592
|
existing_item: existingItem,
|
|
@@ -609,7 +598,6 @@ export function queuePersonUpdate(
|
|
|
609
598
|
persona_name: chunk.personaDisplayName,
|
|
610
599
|
participant_context: buildParticipantContext(primaryPersonaIdForUpdate, state),
|
|
611
600
|
known_identifier_types: userIdentifierTypes,
|
|
612
|
-
persona_entity: personaEntitySnapshot,
|
|
613
601
|
});
|
|
614
602
|
|
|
615
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;
|
|
@@ -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
|
|