ei-tui 0.3.5 → 0.3.7
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/README.md +14 -4
- package/package.json +1 -1
- package/src/cli/README.md +5 -2
- package/src/cli/mcp.ts +30 -2
- package/src/cli/persona-filter.ts +54 -0
- package/src/cli.ts +36 -1
- package/src/core/handlers/human-extraction.ts +1 -0
- package/src/core/handlers/human-matching.ts +6 -0
- package/src/core/handlers/rewrite.ts +18 -1
- package/src/core/human-data-manager.ts +17 -5
- package/src/core/orchestrators/ceremony.ts +2 -2
- package/src/core/processor.ts +2 -2
- package/src/core/state-manager.ts +24 -0
- package/src/core/tools/builtin/read-memory.ts +25 -4
- package/src/core/types/data-items.ts +2 -0
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ Personas can use tools. Not just read-from-memory tools — *actual* tools. Web
|
|
|
124
124
|
|
|
125
125
|
| Tool | What it does |
|
|
126
126
|
|------|-------------|
|
|
127
|
-
| `read_memory` | Semantic search of your personal memory — facts, traits, topics, people, quotes. Personas call this automatically when the conversation touches something they might know about you. |
|
|
127
|
+
| `read_memory` | Semantic search of your personal memory — facts, traits, topics, people, quotes. Personas call this automatically when the conversation touches something they might know about you. Supports the `persona` filter to scope results to what a specific persona has learned. |
|
|
128
128
|
| `file_read` | Read a file from your local filesystem *(TUI only)* |
|
|
129
129
|
| `list_directory` | Explore folder structure *(TUI only)* |
|
|
130
130
|
| `directory_tree` | Recursive directory tree *(TUI only)* |
|
|
@@ -188,11 +188,21 @@ Without this setting, browser security policies will block API calls.
|
|
|
188
188
|
|
|
189
189
|
## Development
|
|
190
190
|
|
|
191
|
+
To run the full test suite on a new machine:
|
|
192
|
+
|
|
191
193
|
```bash
|
|
194
|
+
nvm install 20
|
|
195
|
+
nvm use 20
|
|
196
|
+
npm install
|
|
197
|
+
cd web && npm install && npx playwright install && cd ..
|
|
198
|
+
cd tui
|
|
199
|
+
bun install
|
|
200
|
+
npm install
|
|
201
|
+
npm rebuild # compile native PTY module for Node 20 (one-time, new machine only)
|
|
202
|
+
cd ..
|
|
203
|
+
nvm use default
|
|
192
204
|
npm install
|
|
193
|
-
npm run
|
|
194
|
-
npm run build # Compile TypeScript
|
|
195
|
-
npm run test # Run tests
|
|
205
|
+
npm run test:all
|
|
196
206
|
```
|
|
197
207
|
|
|
198
208
|
## Releases
|
package/package.json
CHANGED
package/src/cli/README.md
CHANGED
|
@@ -9,6 +9,7 @@ ei facts -n 5 "query string" # Return up to 5 facts
|
|
|
9
9
|
ei people -n 5 "query string" # Return up to 5 people
|
|
10
10
|
ei topics -n 5 "query string" # Return up to 5 topics
|
|
11
11
|
ei quotes -n 5 "query string" # Return up to 5 quotes
|
|
12
|
+
ei --persona "Beta" "query string" # Filter results to what Beta has learned
|
|
12
13
|
ei --id <id> # Look up a specific entity by ID
|
|
13
14
|
echo <id> | ei --id # Look up entity by ID from stdin
|
|
14
15
|
ei --install # Register Ei with OpenCode, Claude Code, and Cursor
|
|
@@ -68,7 +69,7 @@ ei "What are the user's current preferences, active projects, and workflow?"
|
|
|
68
69
|
|
|
69
70
|
Ei is a persistent knowledge base built from the user's conversations — facts, preferences,
|
|
70
71
|
people, topics. Use it when the user references past work, mentions how they like things done,
|
|
71
|
-
or asks "how did we do X." Query again mid-session when they correct you or reference something
|
|
72
|
+
or asks "how did we do X." Use `ei --persona "Beta" "walruses"` to scope results to what a specific persona has learned. Query again mid-session when they correct you or reference something
|
|
72
73
|
from a previous session.
|
|
73
74
|
```
|
|
74
75
|
|
|
@@ -79,6 +80,7 @@ Add to `~/.claude/CLAUDE.md` (user-level) or `CLAUDE.md` at project root:
|
|
|
79
80
|
```markdown
|
|
80
81
|
At session start, use the **ei** MCP to pull user context: call `ei_search` with a
|
|
81
82
|
natural-language query about the user's preferences, active projects, and workflow.
|
|
83
|
+
A `persona` filter is available to scope results to what a specific persona has learned.
|
|
82
84
|
|
|
83
85
|
Use Ei when the user references past decisions, mentions people or preferences, or asks
|
|
84
86
|
"how did we do X." Query again when they correct you or reference something from a previous
|
|
@@ -109,7 +111,7 @@ conversations (facts, people, topics, quotes).
|
|
|
109
111
|
|
|
110
112
|
**How to use:**
|
|
111
113
|
1. Call `ei_search` (server `user-ei`) with a natural-language query; optionally filter by
|
|
112
|
-
`type
|
|
114
|
+
`type` (facts, people, topics, quotes) or `persona` display_name.
|
|
113
115
|
2. If you need full detail for a result, call `ei_lookup` with the entity `id` from step 1.
|
|
114
116
|
|
|
115
117
|
Prefer querying Ei before asking the user for context they may have already shared.
|
|
@@ -122,6 +124,7 @@ The installed tool gives OpenCode agents access to all four data types with prop
|
|
|
122
124
|
| Arg | Type | Description |
|
|
123
125
|
|-----|------|-------------|
|
|
124
126
|
| `query` | string (required) | Search text, or entity ID when `lookup=true` |
|
|
127
|
+
| `persona` | string (optional) | Persona display_name to filter results — only returns entities that persona has extracted |
|
|
125
128
|
| `type` | enum (optional) | `facts` \| `people` \| `topics` \| `quotes` — omit for balanced results |
|
|
126
129
|
| `limit` | number (optional) | Max results, default 10 |
|
|
127
130
|
| `lookup` | boolean (optional) | If true, fetch single entity by ID |
|
package/src/cli/mcp.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { retrieveBalanced, lookupById } from "./retrieval.js";
|
|
4
|
+
import { retrieveBalanced, lookupById, loadLatestState, type BalancedResult } from "./retrieval.js";
|
|
5
|
+
import type { StorageState } from "../core/types/index.js";
|
|
6
|
+
import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona } from "./persona-filter.js";
|
|
5
7
|
|
|
6
8
|
// Exported so tests can inject their own transport
|
|
7
9
|
export function createMcpServer(): McpServer {
|
|
@@ -23,6 +25,12 @@ export function createMcpServer(): McpServer {
|
|
|
23
25
|
.describe(
|
|
24
26
|
"Filter to a specific data type. Omit to search all types (balanced across all 4)."
|
|
25
27
|
),
|
|
28
|
+
persona: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"Filter to entities a specific persona has learned about. Use the persona display name."
|
|
33
|
+
),
|
|
26
34
|
limit: z
|
|
27
35
|
.number()
|
|
28
36
|
.optional()
|
|
@@ -34,16 +42,36 @@ export function createMcpServer(): McpServer {
|
|
|
34
42
|
.describe("If true, sort by most recently mentioned."),
|
|
35
43
|
},
|
|
36
44
|
},
|
|
37
|
-
async ({ query, type, limit, recent }) => {
|
|
45
|
+
async ({ query, type, persona, limit, recent }) => {
|
|
38
46
|
const options = { recent: recent ?? false };
|
|
39
47
|
const effectiveLimit = limit ?? 10;
|
|
40
48
|
|
|
49
|
+
let state: StorageState | null = null;
|
|
50
|
+
let personaId: string | undefined;
|
|
51
|
+
if (persona) {
|
|
52
|
+
state = await loadLatestState();
|
|
53
|
+
if (state) {
|
|
54
|
+
personaId = resolvePersonaId(state, persona) ?? undefined;
|
|
55
|
+
if (!personaId) {
|
|
56
|
+
return {
|
|
57
|
+
content: [{ type: "text" as const, text: `Persona "${persona}" not found.` }],
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
41
63
|
let result: unknown;
|
|
42
64
|
if (type) {
|
|
43
65
|
const module = await import(`./commands/${type}.js`);
|
|
44
66
|
result = await (module.execute as (q: string, l: number, o: { recent: boolean }) => Promise<unknown>)(query, effectiveLimit, options);
|
|
67
|
+
if (personaId && state) {
|
|
68
|
+
result = filterTypeSpecificByPersona(result as { id: string }[], state, personaId, type);
|
|
69
|
+
}
|
|
45
70
|
} else {
|
|
46
71
|
result = await retrieveBalanced(query, effectiveLimit, options);
|
|
72
|
+
if (personaId && state) {
|
|
73
|
+
result = filterByPersona(result as BalancedResult[], state, personaId);
|
|
74
|
+
}
|
|
47
75
|
}
|
|
48
76
|
|
|
49
77
|
return {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { StorageState } from "../core/types/index.js";
|
|
2
|
+
import type { BalancedResult } from "./retrieval.js";
|
|
3
|
+
|
|
4
|
+
export function resolvePersonaId(state: StorageState, name: string): string | null {
|
|
5
|
+
const lowerName = name.toLowerCase();
|
|
6
|
+
for (const { entity } of Object.values(state.personas)) {
|
|
7
|
+
if (entity.display_name.toLowerCase() === lowerName) {
|
|
8
|
+
return entity.id;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function filterByPersona(results: BalancedResult[], state: StorageState, personaId: string): BalancedResult[] {
|
|
15
|
+
return results.filter((result) => {
|
|
16
|
+
if (result.type === "quote") {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
const { id } = result;
|
|
20
|
+
let original: { interested_personas?: string[] } | undefined;
|
|
21
|
+
if (result.type === "fact") {
|
|
22
|
+
original = state.human.facts.find((f) => f.id === id);
|
|
23
|
+
} else if (result.type === "topic") {
|
|
24
|
+
original = state.human.topics.find((t) => t.id === id);
|
|
25
|
+
} else if (result.type === "person") {
|
|
26
|
+
original = state.human.people.find((p) => p.id === id);
|
|
27
|
+
}
|
|
28
|
+
return original?.interested_personas?.includes(personaId) ?? false;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function filterTypeSpecificByPersona<T extends { id: string }>(
|
|
33
|
+
results: T[],
|
|
34
|
+
state: StorageState,
|
|
35
|
+
personaId: string,
|
|
36
|
+
targetType: string
|
|
37
|
+
): T[] {
|
|
38
|
+
if (targetType === "quotes") {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
const collection =
|
|
42
|
+
targetType === "facts"
|
|
43
|
+
? state.human.facts
|
|
44
|
+
: targetType === "topics"
|
|
45
|
+
? state.human.topics
|
|
46
|
+
: targetType === "people"
|
|
47
|
+
? state.human.people
|
|
48
|
+
: null;
|
|
49
|
+
if (!collection) return results;
|
|
50
|
+
return results.filter((r) => {
|
|
51
|
+
const original = collection.find((item) => item.id === r.id) as { interested_personas?: string[] } | undefined;
|
|
52
|
+
return original?.interested_personas?.includes(personaId) ?? false;
|
|
53
|
+
});
|
|
54
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
|
|
14
14
|
import { parseArgs } from "util";
|
|
15
15
|
import { join } from "path";
|
|
16
|
-
import { retrieveBalanced, lookupById } from "./cli/retrieval";
|
|
16
|
+
import { retrieveBalanced, lookupById, loadLatestState } from "./cli/retrieval";
|
|
17
|
+
import type { StorageState } from "./core/types";
|
|
18
|
+
import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona } from "./cli/persona-filter.js";
|
|
17
19
|
|
|
18
20
|
const TYPE_ALIASES: Record<string, string> = {
|
|
19
21
|
quote: "quotes",
|
|
@@ -39,6 +41,7 @@ Usage:
|
|
|
39
41
|
ei --recent Return most recently mentioned items
|
|
40
42
|
ei --recent "query" Filter recent items by query
|
|
41
43
|
ei <type> --recent "query" Type-specific recent search
|
|
44
|
+
ei --persona "Name" "query" Filter results to what a persona has learned
|
|
42
45
|
ei --id <id> Look up a specific entity by ID
|
|
43
46
|
echo <id> | ei --id Look up entity by ID from stdin
|
|
44
47
|
ei mcp Start the Ei MCP stdio server (for Cursor/Claude Desktop)
|
|
@@ -52,6 +55,7 @@ Types:
|
|
|
52
55
|
Options:
|
|
53
56
|
--number, -n Maximum number of results (default: 10)
|
|
54
57
|
--recent, -r Sort by last_mentioned date (most recent first)
|
|
58
|
+
--persona, -p Filter to entities a specific persona has learned about
|
|
55
59
|
--id Look up entity by ID (accepts value or stdin)
|
|
56
60
|
--install Register Ei with OpenCode, Claude Code, and Cursor
|
|
57
61
|
--help, -h Show this help message
|
|
@@ -62,6 +66,7 @@ Examples:
|
|
|
62
66
|
ei quote "you guessed it" # Search quotes only
|
|
63
67
|
ei --recent # Most recently mentioned items
|
|
64
68
|
ei topics --recent "work" # Recent work-related topics
|
|
69
|
+
ei --persona "Architect" "work stuff" # What Architect knows about work
|
|
65
70
|
ei --id abc-123 # Look up entity by ID
|
|
66
71
|
ei "memory leak" | jq .[0].id | ei --id # Pipe ID from search
|
|
67
72
|
`);
|
|
@@ -88,6 +93,12 @@ function buildOpenCodeToolContent(): string {
|
|
|
88
93
|
' .describe(',
|
|
89
94
|
' "Filter to a specific data type. Omit to search all types (balanced across all 4).",',
|
|
90
95
|
' ),',
|
|
96
|
+
' persona: tool.schema',
|
|
97
|
+
' .string()',
|
|
98
|
+
' .optional()',
|
|
99
|
+
' .describe(',
|
|
100
|
+
' "Filter to entities a specific persona has learned about. Use the persona display name.",',
|
|
101
|
+
' ),',
|
|
91
102
|
' limit: tool.schema',
|
|
92
103
|
' .number()',
|
|
93
104
|
' .int()',
|
|
@@ -108,6 +119,7 @@ function buildOpenCodeToolContent(): string {
|
|
|
108
119
|
' cmd.push("--id", args.query);',
|
|
109
120
|
' } else {',
|
|
110
121
|
' if (args.type) cmd.push(args.type);',
|
|
122
|
+
' if (args.persona) cmd.push("--persona", args.persona);',
|
|
111
123
|
' if (args.limit && args.limit !== 10) cmd.push("-n", String(args.limit));',
|
|
112
124
|
' cmd.push(args.query);',
|
|
113
125
|
' }',
|
|
@@ -292,6 +304,7 @@ async function main(): Promise<void> {
|
|
|
292
304
|
options: {
|
|
293
305
|
number: { type: "string", short: "n" },
|
|
294
306
|
recent: { type: "boolean", short: "r" },
|
|
307
|
+
persona: { type: "string", short: "p" },
|
|
295
308
|
help: { type: "boolean", short: "h" },
|
|
296
309
|
},
|
|
297
310
|
allowPositionals: true,
|
|
@@ -310,6 +323,7 @@ async function main(): Promise<void> {
|
|
|
310
323
|
const query = parsed.positionals.join(" ").trim();
|
|
311
324
|
const limit = parsed.values.number ? parseInt(parsed.values.number, 10) : 10;
|
|
312
325
|
const recent = parsed.values.recent === true;
|
|
326
|
+
const personaName = parsed.values.persona?.trim();
|
|
313
327
|
|
|
314
328
|
if (!query && !recent) {
|
|
315
329
|
if (targetType) {
|
|
@@ -325,14 +339,35 @@ async function main(): Promise<void> {
|
|
|
325
339
|
process.exit(1);
|
|
326
340
|
}
|
|
327
341
|
|
|
342
|
+
let state: StorageState | null = null;
|
|
343
|
+
let personaId: string | undefined;
|
|
344
|
+
if (personaName) {
|
|
345
|
+
state = await loadLatestState();
|
|
346
|
+
if (!state) {
|
|
347
|
+
console.error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
personaId = resolvePersonaId(state, personaName) ?? undefined;
|
|
351
|
+
if (!personaId) {
|
|
352
|
+
console.error(`Persona "${personaName}" not found.`);
|
|
353
|
+
process.exit(1);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
328
357
|
const options = { recent };
|
|
329
358
|
|
|
330
359
|
let result;
|
|
331
360
|
if (targetType) {
|
|
332
361
|
const module = await import(`./cli/commands/${targetType}.js`);
|
|
333
362
|
result = await module.execute(query, limit, options);
|
|
363
|
+
if (personaId && state) {
|
|
364
|
+
result = filterTypeSpecificByPersona(result, state, personaId, targetType);
|
|
365
|
+
}
|
|
334
366
|
} else {
|
|
335
367
|
result = await retrieveBalanced(query, limit, options);
|
|
368
|
+
if (personaId && state) {
|
|
369
|
+
result = filterByPersona(result, state, personaId);
|
|
370
|
+
}
|
|
336
371
|
}
|
|
337
372
|
|
|
338
373
|
console.log(JSON.stringify(result, null, 2));
|
|
@@ -72,6 +72,7 @@ export async function handleFactFind(response: LLMResponse, state: StateManager)
|
|
|
72
72
|
last_mentioned: now,
|
|
73
73
|
learned_by: existingFact.learned_by ?? context.personaId,
|
|
74
74
|
last_changed_by: context.personaId,
|
|
75
|
+
interested_personas: [...new Set([...(existingFact.interested_personas ?? []), context.personaId])],
|
|
75
76
|
embedding,
|
|
76
77
|
};
|
|
77
78
|
|
|
@@ -165,6 +165,9 @@ export async function handleTopicUpdate(response: LLMResponse, state: StateManag
|
|
|
165
165
|
last_mentioned: now,
|
|
166
166
|
learned_by: isNewItem ? personaId : existingTopic?.learned_by,
|
|
167
167
|
last_changed_by: personaId,
|
|
168
|
+
interested_personas: isNewItem
|
|
169
|
+
? [personaId]
|
|
170
|
+
: [...new Set([...(existingTopic?.interested_personas ?? []), personaId])],
|
|
168
171
|
persona_groups: mergeGroups(personaGroup, isNewItem, existingTopic?.persona_groups),
|
|
169
172
|
embedding,
|
|
170
173
|
};
|
|
@@ -232,6 +235,9 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
232
235
|
last_mentioned: now,
|
|
233
236
|
learned_by: isNewItem ? personaId : existingPerson?.learned_by,
|
|
234
237
|
last_changed_by: personaId,
|
|
238
|
+
interested_personas: isNewItem
|
|
239
|
+
? [personaId]
|
|
240
|
+
: [...new Set([...(existingPerson?.interested_personas ?? []), personaId])],
|
|
235
241
|
persona_groups: mergeGroups(personaGroup, isNewItem, existingPerson?.persona_groups),
|
|
236
242
|
embedding,
|
|
237
243
|
};
|
|
@@ -36,7 +36,15 @@ export async function handleRewriteScan(response: LLMResponse, state: StateManag
|
|
|
36
36
|
|
|
37
37
|
const subjects = response.parsed as RewriteScanResult | undefined;
|
|
38
38
|
if (!subjects || !Array.isArray(subjects) || subjects.length === 0) {
|
|
39
|
-
console.log(`[handleRewriteScan] No extra subjects found for ${itemType} "${itemId}" —
|
|
39
|
+
console.log(`[handleRewriteScan] No extra subjects found for ${itemType} "${itemId}" — marking rewrite_checked`);
|
|
40
|
+
const human = state.getHuman();
|
|
41
|
+
if (itemType === "topic") {
|
|
42
|
+
const topic = human.topics.find(t => t.id === itemId);
|
|
43
|
+
if (topic) state.human_topic_upsert({ ...topic, rewrite_checked: true });
|
|
44
|
+
} else if (itemType === "person") {
|
|
45
|
+
const person = human.people.find(p => p.id === itemId);
|
|
46
|
+
if (person) state.human_person_upsert({ ...person, rewrite_checked: true });
|
|
47
|
+
}
|
|
40
48
|
return;
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -246,5 +254,14 @@ export async function handleRewriteRewrite(response: LLMResponse, state: StateMa
|
|
|
246
254
|
newCount++;
|
|
247
255
|
}
|
|
248
256
|
|
|
257
|
+
const updatedHuman = state.getHuman();
|
|
258
|
+
if (itemType === "topic") {
|
|
259
|
+
const original = updatedHuman.topics.find(t => t.id === itemId);
|
|
260
|
+
if (original) state.human_topic_upsert({ ...original, rewrite_checked: true });
|
|
261
|
+
} else if (itemType === "person") {
|
|
262
|
+
const original = updatedHuman.people.find(p => p.id === itemId);
|
|
263
|
+
if (original) state.human_person_upsert({ ...original, rewrite_checked: true });
|
|
264
|
+
}
|
|
265
|
+
|
|
249
266
|
console.log(`[handleRewriteRewrite] Complete for ${itemType} "${itemId}": ${existingCount} existing updated, ${newCount} new created`);
|
|
250
267
|
}
|
|
@@ -145,14 +145,14 @@ export async function getQuotesForMessage(sm: StateManager, messageId: string):
|
|
|
145
145
|
export async function searchHumanData(
|
|
146
146
|
sm: StateManager,
|
|
147
147
|
query: string,
|
|
148
|
-
options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean } = {}
|
|
148
|
+
options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean; persona_filter?: string } = {}
|
|
149
149
|
): Promise<{
|
|
150
150
|
facts: Fact[];
|
|
151
151
|
topics: Topic[];
|
|
152
152
|
people: Person[];
|
|
153
153
|
quotes: Quote[];
|
|
154
154
|
}> {
|
|
155
|
-
const { types = ["fact", "topic", "person", "quote"], limit = 10, recent } = options;
|
|
155
|
+
const { types = ["fact", "topic", "person", "quote"], limit = 10, recent, persona_filter } = options;
|
|
156
156
|
const human = sm.getHuman();
|
|
157
157
|
const SIMILARITY_THRESHOLD = 0.3;
|
|
158
158
|
|
|
@@ -214,18 +214,30 @@ export async function searchHumanData(
|
|
|
214
214
|
};
|
|
215
215
|
|
|
216
216
|
if (types.includes("fact")) {
|
|
217
|
-
|
|
217
|
+
let facts = human.facts;
|
|
218
|
+
if (persona_filter) {
|
|
219
|
+
facts = facts.filter(f => f.interested_personas?.includes(persona_filter));
|
|
220
|
+
}
|
|
221
|
+
result.facts = searchItems(facts, (f) => `${f.name} ${f.description || ""}`).map(
|
|
218
222
|
stripDataItemEmbedding
|
|
219
223
|
);
|
|
220
224
|
}
|
|
221
225
|
if (types.includes("topic")) {
|
|
222
|
-
|
|
226
|
+
let topics = human.topics;
|
|
227
|
+
if (persona_filter) {
|
|
228
|
+
topics = topics.filter(t => t.interested_personas?.includes(persona_filter));
|
|
229
|
+
}
|
|
230
|
+
result.topics = searchItems(topics, (t) => `${t.name} ${t.description || ""}`).map(
|
|
223
231
|
stripDataItemEmbedding
|
|
224
232
|
);
|
|
225
233
|
}
|
|
226
234
|
if (types.includes("person")) {
|
|
235
|
+
let people = human.people;
|
|
236
|
+
if (persona_filter) {
|
|
237
|
+
people = people.filter(p => p.interested_personas?.includes(persona_filter));
|
|
238
|
+
}
|
|
227
239
|
result.people = searchItems(
|
|
228
|
-
|
|
240
|
+
people,
|
|
229
241
|
(p) => `${p.name} ${p.description || ""} ${p.relationship}`
|
|
230
242
|
).map(stripDataItemEmbedding);
|
|
231
243
|
}
|
|
@@ -617,12 +617,12 @@ export function queueRewritePhase(state: StateManager): void {
|
|
|
617
617
|
const itemsToScan: Array<{ item: DataItemBase; type: RewriteItemType }> = [];
|
|
618
618
|
|
|
619
619
|
for (const topic of human.topics) {
|
|
620
|
-
if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
|
|
620
|
+
if ((topic.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !topic.rewrite_checked) {
|
|
621
621
|
itemsToScan.push({ item: topic, type: "topic" });
|
|
622
622
|
}
|
|
623
623
|
}
|
|
624
624
|
for (const person of human.people) {
|
|
625
|
-
if ((person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD) {
|
|
625
|
+
if ((person.description?.length ?? 0) > REWRITE_DESCRIPTION_THRESHOLD && !person.rewrite_checked) {
|
|
626
626
|
itemsToScan.push({ item: person, type: "person" });
|
|
627
627
|
}
|
|
628
628
|
}
|
package/src/core/processor.ts
CHANGED
|
@@ -203,7 +203,7 @@ export class Processor {
|
|
|
203
203
|
this.bootstrapTools();
|
|
204
204
|
this.seedBuiltinFacts();
|
|
205
205
|
this.seedSettings();
|
|
206
|
-
registerReadMemoryExecutor(createReadMemoryExecutor(this.searchHumanData.bind(this)));
|
|
206
|
+
registerReadMemoryExecutor(createReadMemoryExecutor(this.searchHumanData.bind(this), this.getPersonaList.bind(this)));
|
|
207
207
|
if (this.isTUI) {
|
|
208
208
|
await registerFileReadExecutor();
|
|
209
209
|
}
|
|
@@ -1485,7 +1485,7 @@ const toolNextSteps = new Set([
|
|
|
1485
1485
|
|
|
1486
1486
|
async searchHumanData(
|
|
1487
1487
|
query: string,
|
|
1488
|
-
options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean } = {}
|
|
1488
|
+
options: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean; persona_filter?: string } = {}
|
|
1489
1489
|
): Promise<{
|
|
1490
1490
|
facts: Fact[];
|
|
1491
1491
|
topics: Topic[];
|
|
@@ -46,6 +46,7 @@ export class StateManager {
|
|
|
46
46
|
this.migrateLearnedByToIds();
|
|
47
47
|
this.migrateFactValidation();
|
|
48
48
|
this.migrateMessageFlags();
|
|
49
|
+
this.migrateInterestedPersonas();
|
|
49
50
|
} else {
|
|
50
51
|
this.humanState.load(createDefaultHumanEntity());
|
|
51
52
|
}
|
|
@@ -191,6 +192,29 @@ export class StateManager {
|
|
|
191
192
|
}
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Migration: interested_personas was added to DataItemBase.
|
|
197
|
+
* On load, backfill from learned_by + last_changed_by for any item missing the field.
|
|
198
|
+
*/
|
|
199
|
+
private migrateInterestedPersonas(): void {
|
|
200
|
+
const human = this.humanState.get();
|
|
201
|
+
let dirty = false;
|
|
202
|
+
|
|
203
|
+
const migrateItem = (item: { learned_by?: string; last_changed_by?: string; interested_personas?: string[] }) => {
|
|
204
|
+
if (item.interested_personas === undefined || item.interested_personas === null) {
|
|
205
|
+
item.interested_personas = [...new Set([item.learned_by, item.last_changed_by].filter(Boolean) as string[])];
|
|
206
|
+
dirty = true;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
[...human.facts, ...human.topics, ...human.people].forEach(migrateItem);
|
|
211
|
+
|
|
212
|
+
if (dirty) {
|
|
213
|
+
this.humanState.set(human);
|
|
214
|
+
console.log("[StateManager] Migrated interested_personas fields from learned_by + last_changed_by");
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
194
218
|
/**
|
|
195
219
|
* Returns true if value looks like a persona ID (UUID or the special "ei" id).
|
|
196
220
|
* Display names are free-form strings that won't match UUID format.
|
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
import type { ToolExecutor } from "../types.js";
|
|
2
2
|
import type { Fact, Topic, Person, Quote } from "../../types.js";
|
|
3
3
|
|
|
4
|
+
interface PersonaSummary {
|
|
5
|
+
id: string;
|
|
6
|
+
display_name: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
4
9
|
type SearchHumanData = (
|
|
5
10
|
query: string,
|
|
6
|
-
options?: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean }
|
|
11
|
+
options?: { types?: Array<"fact" | "topic" | "person" | "quote">; limit?: number; recent?: boolean; persona_filter?: string }
|
|
7
12
|
) => Promise<{ facts: Fact[]; topics: Topic[]; people: Person[]; quotes: Quote[] }>;
|
|
8
13
|
|
|
9
|
-
|
|
14
|
+
type GetPersonaList = () => Promise<PersonaSummary[]>;
|
|
15
|
+
|
|
16
|
+
export function createReadMemoryExecutor(searchHumanData: SearchHumanData, getPersonaList?: GetPersonaList): ToolExecutor {
|
|
10
17
|
return {
|
|
11
18
|
name: "read_memory",
|
|
12
19
|
|
|
13
20
|
async execute(args: Record<string, unknown>): Promise<string> {
|
|
14
21
|
const query = typeof args.query === "string" ? args.query.trim() : "";
|
|
15
22
|
const recent = args.recent === true;
|
|
16
|
-
|
|
23
|
+
const personaArg = typeof args.persona === "string" ? args.persona.trim() : "";
|
|
24
|
+
console.log(`[read_memory] called with query="${query}", types=${JSON.stringify(args.types ?? null)}, limit=${args.limit ?? 10}, recent=${recent}, persona="${personaArg}"`);
|
|
17
25
|
|
|
18
26
|
if (!query && !recent) {
|
|
19
27
|
console.warn("[read_memory] missing query argument");
|
|
@@ -28,7 +36,20 @@ export function createReadMemoryExecutor(searchHumanData: SearchHumanData): Tool
|
|
|
28
36
|
|
|
29
37
|
const limit = typeof args.limit === "number" && args.limit > 0 ? Math.min(args.limit, 20) : 10;
|
|
30
38
|
|
|
31
|
-
|
|
39
|
+
// Resolve persona display_name to ID
|
|
40
|
+
let persona_filter: string | undefined;
|
|
41
|
+
if (personaArg && getPersonaList) {
|
|
42
|
+
const personas = await getPersonaList();
|
|
43
|
+
const match = personas.find(p => p.display_name.toLowerCase() === personaArg.toLowerCase());
|
|
44
|
+
if (match) {
|
|
45
|
+
persona_filter = match.id;
|
|
46
|
+
console.log(`[read_memory] resolved persona "${personaArg}" to ID "${persona_filter}"`);
|
|
47
|
+
} else {
|
|
48
|
+
console.warn(`[read_memory] persona "${personaArg}" not found, proceeding without filter`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const results = await searchHumanData(query, { types, limit, recent, persona_filter });
|
|
32
53
|
|
|
33
54
|
const total = results.facts.length + results.topics.length + results.people.length + results.quotes.length;
|
|
34
55
|
console.log(`[read_memory] query="${query}" => ${total} results (facts=${results.facts.length}, topics=${results.topics.length}, people=${results.people.length}, quotes=${results.quotes.length})`);
|
|
@@ -13,8 +13,10 @@ export interface DataItemBase {
|
|
|
13
13
|
last_mentioned?: string; // Set by extraction only, never ceremony. Used for --recent sorting.
|
|
14
14
|
learned_by?: string; // Persona ID that originally learned this item (stable UUID)
|
|
15
15
|
last_changed_by?: string; // Persona ID that most recently updated this item (stable UUID)
|
|
16
|
+
interested_personas?: string[]; // Persona IDs that have extracted/touched this item (accumulated)
|
|
16
17
|
persona_groups?: string[];
|
|
17
18
|
embedding?: number[];
|
|
19
|
+
rewrite_checked?: boolean; // True after rewrite scan finds no changes. Cleared automatically when extraction upserts a fresh item.
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
export interface Fact extends DataItemBase {
|