ei-tui 1.6.8 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +2 -1
- package/skills/coding-harness-reflect/SKILL.md +230 -0
- package/skills/ei-curate/SKILL.md +174 -0
- package/skills/ei-curate/references/cli.md +160 -0
- package/skills/ei-curate/references/provenance.md +88 -0
- package/skills/ei-curate/references/recipes.md +144 -0
- package/skills/ei-curate/references/talking-to-the-user.md +71 -0
- package/skills/ei-persona/SKILL.md +238 -0
- package/skills/ei-persona/references/cli.md +265 -0
- package/skills/ei-persona/references/recipes.md +247 -0
- package/skills/ei-persona/references/talking-to-the-user.md +107 -0
- package/src/cli/README.md +72 -2
- package/src/cli/commands/personas.ts +46 -1
- package/src/cli/corrections-endpoints.ts +297 -0
- package/src/cli/corrections-writer.ts +138 -0
- package/src/cli/install.ts +252 -157
- package/src/cli/mcp.ts +80 -1
- package/src/cli/persona-corrections.ts +442 -0
- package/src/cli/retrieval.ts +46 -2
- package/src/cli.ts +148 -1
- package/src/core/corrections.ts +233 -0
- package/src/core/handlers/human-extraction.ts +8 -2
- package/src/core/handlers/human-matching.ts +2 -2
- package/src/core/llm-client.ts +7 -1
- package/src/core/orchestrators/human-extraction.ts +1 -0
- package/src/core/persona-tools.ts +92 -0
- package/src/core/personas/opencode-agent.ts +1 -3
- package/src/core/processor.ts +113 -1
- package/src/core/state/human.ts +10 -0
- package/src/core/state/personas.ts +8 -0
- package/src/core/state-manager.ts +11 -0
- package/src/core/types/entities.ts +1 -0
- package/src/core/utils/identifier-utils.ts +3 -2
- package/src/integrations/pi/importer.ts +142 -50
- package/src/integrations/pi/reader.ts +1 -0
- package/src/integrations/pi/types.ts +4 -0
- package/src/storage/file-lock.ts +120 -0
- package/tui/README.md +2 -0
- package/tui/src/commands/provider.tsx +1 -1
- package/tui/src/components/WelcomeOverlay.tsx +3 -3
- package/tui/src/context/ei.tsx +14 -0
- package/tui/src/index.tsx +13 -1
- package/tui/src/storage/file.ts +15 -83
- package/tui/src/util/instance-lock.ts +3 -2
- package/tui/src/util/provider-detection.ts +4 -2
- package/tui/src/util/yaml-persona.ts +7 -38
package/src/cli/mcp.ts
CHANGED
|
@@ -6,6 +6,8 @@ import type { StorageState } from "../core/types.js";
|
|
|
6
6
|
import type { Message } from "../core/types.js";
|
|
7
7
|
import type { RoomMessage } from "../core/types/rooms.js";
|
|
8
8
|
import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./persona-filter.js";
|
|
9
|
+
import { createEntity, updateEntity, removeEntity, CorrectionValidationError } from "./corrections-endpoints.js";
|
|
10
|
+
import { createPersonaEntity, updatePersonaEntity, removePersonaEntity } from "./persona-corrections.js";
|
|
9
11
|
|
|
10
12
|
// Exported so tests can inject their own transport
|
|
11
13
|
export function createMcpServer(): McpServer {
|
|
@@ -99,7 +101,7 @@ export function createMcpServer(): McpServer {
|
|
|
99
101
|
"ei_lookup",
|
|
100
102
|
{
|
|
101
103
|
description:
|
|
102
|
-
"Retrieve the full record for any Ei entity by ID — facts, topics, people, quotes, or personas. Use when ei_search returns an item and you need its complete details (all fields, traits, topics, identifiers, etc.). Pass the entity id from ei_search results.",
|
|
104
|
+
"Retrieve the full record for any Ei entity by ID — facts, topics, people, quotes, or personas. Use when ei_search returns an item and you need its complete details (all fields, traits, topics, identifiers, etc.). Pass the entity id from ei_search results. For facts, topics, and people, the result also includes a `linked_quotes` array listing every quote whose data_item_ids references this entity — check this before correcting a bad merge/split (e.g. un-merging an over-merged Person) to see the full blast radius.",
|
|
103
105
|
inputSchema: {
|
|
104
106
|
id: z.string().describe("The entity ID to look up."),
|
|
105
107
|
source: z
|
|
@@ -253,6 +255,83 @@ export function createMcpServer(): McpServer {
|
|
|
253
255
|
}
|
|
254
256
|
);
|
|
255
257
|
|
|
258
|
+
server.registerTool(
|
|
259
|
+
"ei_create",
|
|
260
|
+
{
|
|
261
|
+
description:
|
|
262
|
+
"Create a new entity in the user's Ei knowledge base — a fact, topic, person, or persona. Use to add data the extraction pipeline missed, split/correct bad merges, or author a new AI persona's identity (display name, description, traits, topics). The record is validated against the entity type's schema server-side; unknown fields are rejected. Returns the assigned id and the full stored record. Not available for quotes — verifiable-origin data can only be corrected via ei_update, never created.",
|
|
263
|
+
inputSchema: {
|
|
264
|
+
entity_type: z.enum(["fact", "topic", "person", "persona"]).describe("The type of entity to create."),
|
|
265
|
+
data: z
|
|
266
|
+
.record(z.unknown())
|
|
267
|
+
.describe(
|
|
268
|
+
"The entity fields. Fact requires name/description/sentiment/validated_date. Topic requires name/description/sentiment. Person requires a name and/or at least one identifier. Persona requires display_name (traits/topics default to empty arrays, is_paused/is_archived default to false, group_primary/groups_visible default to \"General\" if omitted). Structural validation happens server-side against the entity type's schema."
|
|
269
|
+
),
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
async ({ entity_type, data }) => {
|
|
273
|
+
try {
|
|
274
|
+
if (entity_type === "persona") {
|
|
275
|
+
const { id, record } = await createPersonaEntity(data);
|
|
276
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ id, record }, null, 2) }] };
|
|
277
|
+
}
|
|
278
|
+
const { id, record } = await createEntity(entity_type, data);
|
|
279
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ id, record }, null, 2) }] };
|
|
280
|
+
} catch (e) {
|
|
281
|
+
const message = e instanceof CorrectionValidationError ? e.message : (e as Error).message;
|
|
282
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
server.registerTool(
|
|
288
|
+
"ei_update",
|
|
289
|
+
{
|
|
290
|
+
description:
|
|
291
|
+
"Replace an existing fact, topic, person, quote, or persona by ID with a COMPLETE record — this is full replacement, not a partial patch. Any field omitted from `data` is treated as absent, not 'leave the existing value alone'. Always fetch the current record with ei_lookup first, edit the fields you need to change, and pass the WHOLE thing back — passing a partial object will silently drop the fields you didn't include. Use to fix bad extracted data (e.g. correcting a person record where two people were wrongly merged into one). Quote records can also be corrected this way — specifically to repoint `data_item_ids` after splitting or merging a person/topic/fact, or to fix mistranscribed `text`. Persona records support full CRUD this way too — rewriting display_name/traits[]/topics[] to author a persona's character (e.g. \"make this persona talk like Yoda\"), or toggling `is_archived` (archive/unarchive is just a normal update). Renaming a persona to a reserved name (\"new\", \"clone\") is rejected.",
|
|
292
|
+
inputSchema: {
|
|
293
|
+
entity_type: z.enum(["fact", "topic", "person", "quote", "persona"]).describe("The type of entity to update."),
|
|
294
|
+
id: z.string().describe("The ID of the entity to replace, from ei_lookup or ei_search."),
|
|
295
|
+
data: z
|
|
296
|
+
.record(z.unknown())
|
|
297
|
+
.describe("The COMPLETE replacement record — round-tripped from ei_lookup output, not a partial patch."),
|
|
298
|
+
},
|
|
299
|
+
},
|
|
300
|
+
async ({ entity_type, id, data }) => {
|
|
301
|
+
try {
|
|
302
|
+
const record = entity_type === "persona" ? await updatePersonaEntity(id, data) : await updateEntity(entity_type, id, data);
|
|
303
|
+
return { content: [{ type: "text" as const, text: JSON.stringify(record, null, 2) }] };
|
|
304
|
+
} catch (e) {
|
|
305
|
+
const message = e instanceof CorrectionValidationError ? e.message : (e as Error).message;
|
|
306
|
+
return { content: [{ type: "text" as const, text: `Error: ${message}` }], isError: true };
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
server.registerTool(
|
|
312
|
+
"ei_remove",
|
|
313
|
+
{
|
|
314
|
+
description:
|
|
315
|
+
"Permanently remove a fact, topic, person, or persona from the user's Ei knowledge base by ID. Use to delete bad extracted data that shouldn't be split or corrected, just dropped entirely, or to delete an AI persona that's no longer needed. Not available for quotes — verifiable-origin data can only be corrected, never deleted. Reserved built-in personas (\"ei\", \"emmet\") cannot be deleted this way — use ei_update to set `is_archived: true` instead.",
|
|
316
|
+
inputSchema: {
|
|
317
|
+
entity_type: z.enum(["fact", "topic", "person", "persona"]).describe("The type of entity to remove."),
|
|
318
|
+
id: z.string().describe("The ID of the entity to remove, from ei_lookup or ei_search."),
|
|
319
|
+
},
|
|
320
|
+
},
|
|
321
|
+
async ({ entity_type, id }) => {
|
|
322
|
+
try {
|
|
323
|
+
if (entity_type === "persona") {
|
|
324
|
+
await removePersonaEntity(id);
|
|
325
|
+
} else {
|
|
326
|
+
await removeEntity(entity_type, id);
|
|
327
|
+
}
|
|
328
|
+
return { content: [{ type: "text" as const, text: JSON.stringify({ removed: true, id }, null, 2) }] };
|
|
329
|
+
} catch (e) {
|
|
330
|
+
return { content: [{ type: "text" as const, text: `Error: ${(e as Error).message}` }], isError: true };
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
);
|
|
334
|
+
|
|
256
335
|
return server;
|
|
257
336
|
}
|
|
258
337
|
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validated create/update/remove endpoints for persona corrections —
|
|
3
|
+
* `ei create/update/remove persona` (CLI, src/cli.ts) and
|
|
4
|
+
* `ei_create`/`ei_update`/`ei_remove` with entity_type "persona" (MCP,
|
|
5
|
+
* src/cli/mcp.ts).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately NOT folded into the shared SCHEMAS dispatch in
|
|
8
|
+
* corrections-endpoints.ts — mirrors that module's own updateQuoteEntity,
|
|
9
|
+
* which already bypasses the shared machinery for a type with a distinct
|
|
10
|
+
* shape. PersonaEntity's nested traits[]/topics[] need structural checks
|
|
11
|
+
* (id uniqueness, id auto-assignment) a flat Zod object doesn't give for
|
|
12
|
+
* free, and this keeps Persona's validation free to harden independently
|
|
13
|
+
* of fact/topic/person.
|
|
14
|
+
*
|
|
15
|
+
* Unlike Quote (update-only — quotes are records of external reality that
|
|
16
|
+
* can't be fabricated or destroyed), Persona corrections are full CRUD:
|
|
17
|
+
* personas are authored, disposable-by-design artifacts, so there's no
|
|
18
|
+
* epistemic reason to restrict create/remove. The only restriction is
|
|
19
|
+
* DELETE of a reserved persona (RESERVED_PERSONA_IDS — "ei", "emmet"),
|
|
20
|
+
* rejected SYNCHRONOUSLY in removePersonaEntity below, before the
|
|
21
|
+
* correction is ever queued — see that function's comment for why the
|
|
22
|
+
* check can't just live in Processor.applyCorrectionRecord's drain-time
|
|
23
|
+
* guard. A reserved persona's IDENTITY (display_name, traits, topics, ...)
|
|
24
|
+
* is freely editable through this path; that's expected and desired (Ei
|
|
25
|
+
* self-gendering mid-conversation was the origin of the Reflection
|
|
26
|
+
* feature).
|
|
27
|
+
*
|
|
28
|
+
* Update/create use the same full-object round-trip semantics as
|
|
29
|
+
* fact/topic/person in corrections-endpoints.ts: read the full record,
|
|
30
|
+
* touch only the field you mean to change, write the WHOLE thing back.
|
|
31
|
+
* There are no separate trait/topic sub-endpoints.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { z } from "zod";
|
|
35
|
+
import { loadLatestState } from "./retrieval.js";
|
|
36
|
+
import { writeCorrection } from "./corrections-writer.js";
|
|
37
|
+
import { computePersonaDescriptionEmbedding } from "../core/embedding-service.js";
|
|
38
|
+
import { CorrectionValidationError } from "./corrections-endpoints.js";
|
|
39
|
+
import { isReservedPersonaName, isReservedPersonaId, RESERVED_PERSONA_NAMES } from "../core/types/entities.js";
|
|
40
|
+
import { NOTES_MAX } from "../core/tools/builtin/persona-notes.js";
|
|
41
|
+
import type { PersonaEntity } from "../core/types/entities.js";
|
|
42
|
+
import type { PersonaTrait, PersonaTopic } from "../core/types/data-items.js";
|
|
43
|
+
import type { CorrectionRecord } from "../core/corrections.js";
|
|
44
|
+
import { buildPersonaToolsMap, resolvePersonaToolsFromMap, preserveHiddenToolGrants } from "../core/persona-tools.js";
|
|
45
|
+
import type { ToolDefinition, ToolProvider } from "../core/types/integrations.js";
|
|
46
|
+
|
|
47
|
+
const DEFAULT_GROUP = "General";
|
|
48
|
+
|
|
49
|
+
// Optional DataItemBase metadata pass-through fields a persisted PersonaTrait
|
|
50
|
+
// commonly carries (mirrors corrections-endpoints.ts's `metaFields`,
|
|
51
|
+
// duplicated rather than imported — it's private there, and this keeps this
|
|
52
|
+
// module's only dependency on corrections-endpoints.ts to
|
|
53
|
+
// CorrectionValidationError). Accepted and passed through unchanged; never
|
|
54
|
+
// validated beyond shape since they're opaque bookkeeping, not
|
|
55
|
+
// caller-authored content.
|
|
56
|
+
const traitMetaFields = {
|
|
57
|
+
learned_on: z.string().optional(),
|
|
58
|
+
last_mentioned: z.string().optional(),
|
|
59
|
+
learned_by: z.string().optional(),
|
|
60
|
+
last_changed_by: z.string().optional(),
|
|
61
|
+
interested_personas: z.array(z.string()).optional(),
|
|
62
|
+
sources: z.array(z.string()).optional(),
|
|
63
|
+
persona_groups: z.array(z.string()).optional(),
|
|
64
|
+
rewrite_length_floor: z.number().optional(),
|
|
65
|
+
embedding: z.array(z.number()).optional(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// `id` is auto-assigned below when absent (never required from a caller
|
|
69
|
+
// authoring a brand-new trait/topic). `last_updated` is accepted-but-always-
|
|
70
|
+
// overwritten — declaring it here (rather than omitting it) means a caller
|
|
71
|
+
// round-tripping an unmodified trait/topic from `ei --id <persona>` isn't
|
|
72
|
+
// rejected by strictObject for including a field it never meant to
|
|
73
|
+
// hand-author; materializeTraits/materializeTopics below stamp a fresh
|
|
74
|
+
// value on every write regardless of what's supplied, the same "server
|
|
75
|
+
// always refreshes this" treatment PersonaState.update gives the entity's
|
|
76
|
+
// own last_updated.
|
|
77
|
+
const personaTraitSchema = z.strictObject({
|
|
78
|
+
id: z.string().optional(),
|
|
79
|
+
name: z.string().min(1),
|
|
80
|
+
description: z.string(),
|
|
81
|
+
sentiment: z.number().min(-1).max(1),
|
|
82
|
+
strength: z.number().min(0).max(1).optional(),
|
|
83
|
+
last_updated: z.string().optional(),
|
|
84
|
+
...traitMetaFields,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const personaTopicSchema = z.strictObject({
|
|
88
|
+
id: z.string().optional(),
|
|
89
|
+
name: z.string().min(1),
|
|
90
|
+
perspective: z.string(),
|
|
91
|
+
approach: z.string(),
|
|
92
|
+
personal_stake: z.string(),
|
|
93
|
+
sentiment: z.number().min(-1).max(1),
|
|
94
|
+
exposure_current: z.number().min(0).max(1),
|
|
95
|
+
exposure_desired: z.number().min(0).max(1),
|
|
96
|
+
last_updated: z.string().optional(),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// No minimum trait/topic count is enforced here on purpose — that floor
|
|
100
|
+
// (coding-harness-reflect's "3 traits minimum") exists only as skill-level
|
|
101
|
+
// AGENT guidance today, not a server-side gate, so a single "add one trait"
|
|
102
|
+
// edit is never blocked.
|
|
103
|
+
const personaEntitySchema = z.strictObject({
|
|
104
|
+
display_name: z.string().min(1),
|
|
105
|
+
aliases: z.array(z.string()).optional(),
|
|
106
|
+
short_description: z.string().optional(),
|
|
107
|
+
long_description: z.string().optional(),
|
|
108
|
+
model: z.string().optional(),
|
|
109
|
+
group_primary: z.string().nullable().optional(),
|
|
110
|
+
groups_visible: z.array(z.string()).optional(),
|
|
111
|
+
traits: z.array(personaTraitSchema).default([]),
|
|
112
|
+
topics: z.array(personaTopicSchema).default([]),
|
|
113
|
+
is_paused: z.boolean().default(false),
|
|
114
|
+
pause_until: z.string().optional(),
|
|
115
|
+
is_archived: z.boolean().default(false),
|
|
116
|
+
archived_at: z.string().optional(),
|
|
117
|
+
heartbeat_delay_ms: z.number().optional(),
|
|
118
|
+
context_window_ms: z.number().optional(),
|
|
119
|
+
include_message_timestamps: z.boolean().optional(),
|
|
120
|
+
context_boundary: z.string().optional(),
|
|
121
|
+
tools: z.record(z.string(), z.record(z.string(), z.boolean())).optional(),
|
|
122
|
+
avatar_emoji: z.string().optional(),
|
|
123
|
+
avatar_image: z.string().optional(),
|
|
124
|
+
preferred_theme: z.string().optional(),
|
|
125
|
+
notes: z.array(z.string()).max(NOTES_MAX).optional(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
type PersonaEntityInput = z.infer<typeof personaEntitySchema>;
|
|
129
|
+
|
|
130
|
+
// The external CRUD surface's `tools` contract is the same self-documenting
|
|
131
|
+
// `{ providerDisplayName: { toolDisplayName: boolean } }` map the TUI's
|
|
132
|
+
// $EDITOR/YAML persona editor uses (buildPersonaToolsMap/
|
|
133
|
+
// resolvePersonaToolsFromMap in ../core/persona-tools.js) — never the raw
|
|
134
|
+
// flat ToolDefinition-id array PersonaEntity.tools actually persists as.
|
|
135
|
+
type PersonaEntityWithToolsMap = Omit<PersonaEntity, "tools"> & {
|
|
136
|
+
tools?: Record<string, Record<string, boolean>>;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Server-owned fields silently stripped before schema validation on
|
|
141
|
+
* UPDATE — a caller following the documented `ei --id <persona>` -> edit ->
|
|
142
|
+
* `ei update persona` round-trip naturally sends these back unchanged.
|
|
143
|
+
* Wider than corrections-endpoints.ts's ROUND_TRIP_FIELDS (id/type/
|
|
144
|
+
* last_updated/linked_quotes) because lookupById's crossFind spreads the
|
|
145
|
+
* FULL PersonaEntity (plus its own `type: "persona"` discriminator) rather
|
|
146
|
+
* than a narrower projection: `entity` is a fixed literal, `is_static` is
|
|
147
|
+
* explicitly read-only (distinguishes built-in structural personas — never
|
|
148
|
+
* flippable via this path, so it must round-trip silently rather than
|
|
149
|
+
* error), and last_heartbeat/last_extraction/description_embedding/
|
|
150
|
+
* pending_update/reflection_last_asked are all written only by the live
|
|
151
|
+
* Processor/ceremony pipeline, never by a human or external agent. Any
|
|
152
|
+
* OTHER unknown key still fails validation — this is a narrow allowlist.
|
|
153
|
+
*/
|
|
154
|
+
const PERSONA_ROUND_TRIP_FIELDS = [
|
|
155
|
+
"id",
|
|
156
|
+
"type",
|
|
157
|
+
"entity",
|
|
158
|
+
"is_static",
|
|
159
|
+
"last_updated",
|
|
160
|
+
"last_heartbeat",
|
|
161
|
+
"last_extraction",
|
|
162
|
+
"description_embedding",
|
|
163
|
+
"pending_update",
|
|
164
|
+
"reflection_last_asked",
|
|
165
|
+
] as const;
|
|
166
|
+
|
|
167
|
+
function parsePersonaBody(body: unknown, mode: "create" | "update"): PersonaEntityInput {
|
|
168
|
+
let input: unknown = body;
|
|
169
|
+
if (mode === "update" && body && typeof body === "object") {
|
|
170
|
+
const stripped: Record<string, unknown> = { ...(body as Record<string, unknown>) };
|
|
171
|
+
for (const field of PERSONA_ROUND_TRIP_FIELDS) {
|
|
172
|
+
delete stripped[field];
|
|
173
|
+
}
|
|
174
|
+
input = stripped;
|
|
175
|
+
}
|
|
176
|
+
const result = personaEntitySchema.safeParse(input);
|
|
177
|
+
if (!result.success) {
|
|
178
|
+
throw new CorrectionValidationError(
|
|
179
|
+
`Invalid persona: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
return result.data;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Rejects a reserved display_name on both create and rename-via-update. */
|
|
186
|
+
function assertNotReservedName(displayName: string): void {
|
|
187
|
+
if (isReservedPersonaName(displayName)) {
|
|
188
|
+
throw new CorrectionValidationError(
|
|
189
|
+
`Cannot use reserved name "${displayName}" for a persona. Reserved names: ${RESERVED_PERSONA_NAMES.join(", ")}`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Deliberately stricter than resolvePersonaToolsFromMap's own silent-skip
|
|
196
|
+
* contract (shared with the TUI, which must stay lenient so a round-trip
|
|
197
|
+
* of a slightly stale/partial YAML edit never hard-fails). An external
|
|
198
|
+
* CRUD caller granting a tool it has never granted before has no other
|
|
199
|
+
* way to discover valid provider/tool display names ahead of time, so a
|
|
200
|
+
* typo'd or disabled key here fails loudly — naming the exact bad key —
|
|
201
|
+
* instead of silently resolving to "granted nothing" (the very gap this
|
|
202
|
+
* whole contract change closes: granting a tool under a disabled
|
|
203
|
+
* provider used to be an unannounced no-op).
|
|
204
|
+
*/
|
|
205
|
+
function validatePersonaToolsMap(
|
|
206
|
+
toolsMap: Record<string, Record<string, boolean>>,
|
|
207
|
+
allTools: ToolDefinition[],
|
|
208
|
+
allProviders: ToolProvider[]
|
|
209
|
+
): void {
|
|
210
|
+
for (const [providerDisplayName, toolToggles] of Object.entries(toolsMap)) {
|
|
211
|
+
const provider = allProviders.find((p) => p.display_name === providerDisplayName);
|
|
212
|
+
if (!provider) {
|
|
213
|
+
throw new CorrectionValidationError(`Invalid persona: tools: unknown provider "${providerDisplayName}"`);
|
|
214
|
+
}
|
|
215
|
+
if (!provider.enabled) {
|
|
216
|
+
throw new CorrectionValidationError(`Invalid persona: tools: provider "${providerDisplayName}" is disabled`);
|
|
217
|
+
}
|
|
218
|
+
for (const toolDisplayName of Object.keys(toolToggles)) {
|
|
219
|
+
const tool = allTools.find((t) => t.provider_id === provider.id && t.display_name === toolDisplayName);
|
|
220
|
+
if (!tool) {
|
|
221
|
+
throw new CorrectionValidationError(
|
|
222
|
+
`Invalid persona: tools: unknown tool "${toolDisplayName}" under provider "${providerDisplayName}"`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Assign a fresh id to any trait lacking one and stamp a fresh last_updated on every trait, then reject duplicate ids. */
|
|
230
|
+
function materializeTraits(traits: PersonaEntityInput["traits"], now: string): PersonaTrait[] {
|
|
231
|
+
const materialized: PersonaTrait[] = traits.map((t) => ({ ...t, id: t.id ?? crypto.randomUUID(), last_updated: now }));
|
|
232
|
+
const seen = new Set<string>();
|
|
233
|
+
for (const t of materialized) {
|
|
234
|
+
if (seen.has(t.id)) {
|
|
235
|
+
throw new CorrectionValidationError(`Invalid persona: duplicate trait id "${t.id}"`);
|
|
236
|
+
}
|
|
237
|
+
seen.add(t.id);
|
|
238
|
+
}
|
|
239
|
+
return materialized;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Assign a fresh id to any topic lacking one and stamp a fresh last_updated on every topic, then reject duplicate ids. */
|
|
243
|
+
function materializeTopics(topics: PersonaEntityInput["topics"], now: string): PersonaTopic[] {
|
|
244
|
+
const materialized: PersonaTopic[] = topics.map((t) => ({ ...t, id: t.id ?? crypto.randomUUID(), last_updated: now }));
|
|
245
|
+
const seen = new Set<string>();
|
|
246
|
+
for (const t of materialized) {
|
|
247
|
+
if (seen.has(t.id)) {
|
|
248
|
+
throw new CorrectionValidationError(`Invalid persona: duplicate topic id "${t.id}"`);
|
|
249
|
+
}
|
|
250
|
+
seen.add(t.id);
|
|
251
|
+
}
|
|
252
|
+
return materialized;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Strip `description_embedding` before handing a just-written record back
|
|
257
|
+
* to a caller — mirrors corrections-endpoints.ts's stripEmbedding,
|
|
258
|
+
* duplicated locally (not imported: that helper is private there, and
|
|
259
|
+
* PersonaEntity's embedding field has a different name — description_
|
|
260
|
+
* embedding, not embedding — so it isn't a drop-in reuse anyway).
|
|
261
|
+
*/
|
|
262
|
+
function stripPersonaEmbedding(record: PersonaEntity): PersonaEntity {
|
|
263
|
+
const { description_embedding, ...rest } = record;
|
|
264
|
+
void description_embedding;
|
|
265
|
+
return rest as PersonaEntity;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export async function createPersonaEntity(body: unknown): Promise<{ id: string; record: PersonaEntityWithToolsMap }> {
|
|
269
|
+
const parsed = parsePersonaBody(body, "create");
|
|
270
|
+
assertNotReservedName(parsed.display_name);
|
|
271
|
+
|
|
272
|
+
const state = await loadLatestState();
|
|
273
|
+
if (!state) {
|
|
274
|
+
throw new Error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
275
|
+
}
|
|
276
|
+
const allTools = state.tools ?? [];
|
|
277
|
+
const allProviders = state.providers ?? [];
|
|
278
|
+
if (parsed.tools) {
|
|
279
|
+
validatePersonaToolsMap(parsed.tools, allTools, allProviders);
|
|
280
|
+
}
|
|
281
|
+
const resolvedTools = resolvePersonaToolsFromMap(parsed.tools, allTools, allProviders);
|
|
282
|
+
|
|
283
|
+
const now = new Date().toISOString();
|
|
284
|
+
const id = crypto.randomUUID();
|
|
285
|
+
const traits = materializeTraits(parsed.traits, now);
|
|
286
|
+
const topics = materializeTopics(parsed.topics, now);
|
|
287
|
+
|
|
288
|
+
// Defaults mirror persona-manager.ts's createPersona placeholder shape
|
|
289
|
+
// exactly (lines 55-71) for consistency between the live-app creation
|
|
290
|
+
// path and this headless one — minus its orchestratePersonaGeneration
|
|
291
|
+
// call, which is a live-app-only LLM auto-generation side effect
|
|
292
|
+
// inappropriate for a headless external CLI/MCP call. This path builds
|
|
293
|
+
// the full record directly from validated input; there is no generation
|
|
294
|
+
// fallback.
|
|
295
|
+
const record: PersonaEntity = {
|
|
296
|
+
id,
|
|
297
|
+
display_name: parsed.display_name,
|
|
298
|
+
entity: "system",
|
|
299
|
+
aliases: parsed.aliases ?? [parsed.display_name],
|
|
300
|
+
short_description: parsed.short_description,
|
|
301
|
+
long_description: parsed.long_description,
|
|
302
|
+
model: parsed.model,
|
|
303
|
+
group_primary: parsed.group_primary ?? DEFAULT_GROUP,
|
|
304
|
+
groups_visible: parsed.groups_visible ?? [DEFAULT_GROUP],
|
|
305
|
+
traits,
|
|
306
|
+
topics,
|
|
307
|
+
tools: resolvedTools && resolvedTools.length > 0 ? resolvedTools : undefined,
|
|
308
|
+
is_paused: parsed.is_paused,
|
|
309
|
+
pause_until: parsed.pause_until,
|
|
310
|
+
is_archived: parsed.is_archived,
|
|
311
|
+
archived_at: parsed.archived_at,
|
|
312
|
+
is_static: false,
|
|
313
|
+
heartbeat_delay_ms: parsed.heartbeat_delay_ms,
|
|
314
|
+
context_window_ms: parsed.context_window_ms,
|
|
315
|
+
include_message_timestamps: parsed.include_message_timestamps,
|
|
316
|
+
context_boundary: parsed.context_boundary,
|
|
317
|
+
last_updated: now,
|
|
318
|
+
avatar_emoji: parsed.avatar_emoji,
|
|
319
|
+
avatar_image: parsed.avatar_image,
|
|
320
|
+
preferred_theme: parsed.preferred_theme,
|
|
321
|
+
notes: parsed.notes,
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
if (record.long_description) {
|
|
325
|
+
record.description_embedding = await computePersonaDescriptionEmbedding(record);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const correction: CorrectionRecord = { op: "upsert", entity_type: "persona", id, record, timestamp: now };
|
|
329
|
+
await writeCorrection(correction);
|
|
330
|
+
// Re-enrich record.tools back into the boolean-map shape for the
|
|
331
|
+
// RETURNED copy only, so a create response shows the same shape a
|
|
332
|
+
// subsequent `ei --id` read would show — the queued CorrectionRecord
|
|
333
|
+
// above (and therefore the persisted PersonaEntity) keeps the flat
|
|
334
|
+
// string[] shape, which is the real on-disk contract.
|
|
335
|
+
return {
|
|
336
|
+
id,
|
|
337
|
+
record: {
|
|
338
|
+
...stripPersonaEmbedding(record),
|
|
339
|
+
tools: buildPersonaToolsMap(record.tools ?? [], allTools, allProviders),
|
|
340
|
+
},
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export async function updatePersonaEntity(id: string, body: unknown): Promise<PersonaEntityWithToolsMap> {
|
|
345
|
+
const parsed = parsePersonaBody(body, "update");
|
|
346
|
+
assertNotReservedName(parsed.display_name);
|
|
347
|
+
|
|
348
|
+
const state = await loadLatestState();
|
|
349
|
+
if (!state) {
|
|
350
|
+
throw new Error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
351
|
+
}
|
|
352
|
+
const existing = state.personas[id];
|
|
353
|
+
if (!existing) {
|
|
354
|
+
throw new Error(`No persona found with id: ${id}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const allTools = state.tools ?? [];
|
|
358
|
+
const allProviders = state.providers ?? [];
|
|
359
|
+
if (parsed.tools) {
|
|
360
|
+
validatePersonaToolsMap(parsed.tools, allTools, allProviders);
|
|
361
|
+
}
|
|
362
|
+
const resolvedTools = resolvePersonaToolsFromMap(parsed.tools, allTools, allProviders);
|
|
363
|
+
const finalTools = preserveHiddenToolGrants(resolvedTools, existing.entity.tools, allTools, allProviders);
|
|
364
|
+
|
|
365
|
+
const now = new Date().toISOString();
|
|
366
|
+
const traits = materializeTraits(parsed.traits, now);
|
|
367
|
+
const topics = materializeTopics(parsed.topics, now);
|
|
368
|
+
|
|
369
|
+
// Full-object replace: every writable field comes from `parsed` (absent
|
|
370
|
+
// -> undefined, never "keep the old value") except `is_static`, which
|
|
371
|
+
// isn't part of the writable schema at all and is always inherited from
|
|
372
|
+
// the existing record.
|
|
373
|
+
const record: PersonaEntity = {
|
|
374
|
+
id,
|
|
375
|
+
display_name: parsed.display_name,
|
|
376
|
+
entity: "system",
|
|
377
|
+
aliases: parsed.aliases,
|
|
378
|
+
short_description: parsed.short_description,
|
|
379
|
+
long_description: parsed.long_description,
|
|
380
|
+
model: parsed.model,
|
|
381
|
+
group_primary: parsed.group_primary,
|
|
382
|
+
groups_visible: parsed.groups_visible,
|
|
383
|
+
traits,
|
|
384
|
+
topics,
|
|
385
|
+
tools: finalTools,
|
|
386
|
+
is_paused: parsed.is_paused,
|
|
387
|
+
pause_until: parsed.pause_until,
|
|
388
|
+
is_archived: parsed.is_archived,
|
|
389
|
+
archived_at: parsed.archived_at,
|
|
390
|
+
is_static: existing.entity.is_static,
|
|
391
|
+
heartbeat_delay_ms: parsed.heartbeat_delay_ms,
|
|
392
|
+
context_window_ms: parsed.context_window_ms,
|
|
393
|
+
include_message_timestamps: parsed.include_message_timestamps,
|
|
394
|
+
context_boundary: parsed.context_boundary,
|
|
395
|
+
last_updated: now,
|
|
396
|
+
avatar_emoji: parsed.avatar_emoji,
|
|
397
|
+
avatar_image: parsed.avatar_image,
|
|
398
|
+
preferred_theme: parsed.preferred_theme,
|
|
399
|
+
notes: parsed.notes,
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// Always recompute on update rather than diffing old vs new — simplest
|
|
403
|
+
// correct behavior, and cheap (local embedding model, no network call).
|
|
404
|
+
if (record.long_description) {
|
|
405
|
+
record.description_embedding = await computePersonaDescriptionEmbedding(record);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const correction: CorrectionRecord = { op: "upsert", entity_type: "persona", id, record, timestamp: now };
|
|
409
|
+
await writeCorrection(correction);
|
|
410
|
+
// Re-enrich for the RETURNED copy only -- see createPersonaEntity's
|
|
411
|
+
// matching comment; the persisted record.tools stays the flat string[].
|
|
412
|
+
return {
|
|
413
|
+
...stripPersonaEmbedding(record),
|
|
414
|
+
tools: buildPersonaToolsMap(record.tools ?? [], allTools, allProviders),
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
export async function removePersonaEntity(id: string): Promise<void> {
|
|
419
|
+
const state = await loadLatestState();
|
|
420
|
+
if (!state) {
|
|
421
|
+
throw new Error("No saved state found. Is EI_DATA_PATH set correctly?");
|
|
422
|
+
}
|
|
423
|
+
if (!state.personas[id]) {
|
|
424
|
+
throw new Error(`No persona found with id: ${id}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// CRITICAL: this check MUST run here, synchronously, before
|
|
428
|
+
// writeCorrection is ever called — never only inside
|
|
429
|
+
// Processor.applyCorrectionRecord's drain-time guard. If a live Ei
|
|
430
|
+
// instance is running, `ei remove persona <id>` queues into
|
|
431
|
+
// corrections.json for async pickup ~100ms later; if the ONLY check
|
|
432
|
+
// lived in the drain's apply function, an agent running
|
|
433
|
+
// `ei remove persona ei` would get an immediate "success" from the CLI,
|
|
434
|
+
// then watch the delete silently no-op in the background with no
|
|
435
|
+
// feedback. Throwing here means the correction is never queued at all.
|
|
436
|
+
if (isReservedPersonaId(id)) {
|
|
437
|
+
throw new Error(`Cannot delete reserved persona "${id}". Use archive instead.`);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const correction: CorrectionRecord = { op: "remove", entity_type: "persona", id, timestamp: new Date().toISOString() };
|
|
441
|
+
await writeCorrection(correction);
|
|
442
|
+
}
|
package/src/cli/retrieval.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { readFile } from "fs/promises";
|
|
|
8
8
|
import { getEmbeddingService, findTopK } from "../core/embedding-service";
|
|
9
9
|
import { parseMessageId } from "../core/utils/message-id.js";
|
|
10
10
|
import { getMachineId } from "../integrations/machine-id.js";
|
|
11
|
+
import { readCorrections, applyCorrectionsToState } from "../core/corrections.js";
|
|
12
|
+
import { getCorrectionsPath } from "./corrections-writer.js";
|
|
13
|
+
import { buildPersonaToolsMap } from "../core/persona-tools.js";
|
|
11
14
|
|
|
12
15
|
const STATE_FILE = "state.json";
|
|
13
16
|
const BACKUP_FILE = "state.backup.json";
|
|
@@ -23,15 +26,22 @@ export function getDataPath(): string {
|
|
|
23
26
|
|
|
24
27
|
export async function loadLatestState(): Promise<StorageState | null> {
|
|
25
28
|
const dataPath = getDataPath();
|
|
29
|
+
let state: StorageState | null = null;
|
|
26
30
|
for (const file of [STATE_FILE, BACKUP_FILE]) {
|
|
27
31
|
try {
|
|
28
32
|
const text = await readFile(join(dataPath, file), "utf-8");
|
|
29
|
-
if (text)
|
|
33
|
+
if (text) {
|
|
34
|
+
state = decodeAllEmbeddings(JSON.parse(text) as StorageState);
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
30
37
|
} catch {
|
|
31
38
|
continue;
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
|
-
|
|
41
|
+
if (!state) return null;
|
|
42
|
+
const corrections = await readCorrections(getCorrectionsPath());
|
|
43
|
+
applyCorrectionsToState(state, corrections);
|
|
44
|
+
return state;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export async function retrieve<T extends { id: string; embedding?: number[]; last_updated?: string; last_mentioned?: string }>(
|
|
@@ -83,6 +93,12 @@ export interface LinkedItem {
|
|
|
83
93
|
name: string;
|
|
84
94
|
type: string;
|
|
85
95
|
}
|
|
96
|
+
export interface LinkedQuote {
|
|
97
|
+
id: string;
|
|
98
|
+
text: string;
|
|
99
|
+
speaker: string;
|
|
100
|
+
timestamp: string;
|
|
101
|
+
}
|
|
86
102
|
export interface QuoteResult {
|
|
87
103
|
text: string;
|
|
88
104
|
speaker: string;
|
|
@@ -582,5 +598,33 @@ export async function lookupById(id: string): Promise<({ type: string } & Record
|
|
|
582
598
|
const withoutEmbedding = { ...rest } as Record<string, unknown>;
|
|
583
599
|
delete withoutEmbedding.embedding;
|
|
584
600
|
delete withoutEmbedding.description_embedding;
|
|
601
|
+
|
|
602
|
+
// data_item_ids on a Quote can only point at facts, topics, or people — the other
|
|
603
|
+
// types crossFind can return (quote itself, and the persona-side persona/
|
|
604
|
+
// personaTopic/personaTrait records) sit outside that linkage model entirely, so
|
|
605
|
+
// they never get a linked_quotes field. For the three linkable types, surface
|
|
606
|
+
// which quotes reference this entity: a human correcting a bad merge/split (e.g.
|
|
607
|
+
// un-merging an over-merged Person) needs this blast radius before repointing
|
|
608
|
+
// anything.
|
|
609
|
+
if (type === "fact" || type === "topic" || type === "person") {
|
|
610
|
+
withoutEmbedding.linked_quotes = state.human.quotes
|
|
611
|
+
.filter((q) => q.data_item_ids.includes(id))
|
|
612
|
+
.map((q) => ({ id: q.id, text: q.text, speaker: q.speaker, timestamp: q.timestamp }));
|
|
613
|
+
}
|
|
614
|
+
// A persisted PersonaEntity.tools is a flat array of ToolDefinition ids —
|
|
615
|
+
// opaque to a caller who doesn't already know every tool's UUID. Enrich it
|
|
616
|
+
// into the same self-documenting `{ providerDisplayName: { toolDisplayName:
|
|
617
|
+
// boolean } }` map the TUI's $EDITOR/YAML persona editor uses, so an agent
|
|
618
|
+
// reading a persona via `ei --id` can both see what's granted AND discover
|
|
619
|
+
// what else is grantable (and under which — possibly disabled — provider)
|
|
620
|
+
// without a separate lookup. buildPersonaToolsMap returns undefined when no
|
|
621
|
+
// tools are registered at all; that undefined/absent result is preserved.
|
|
622
|
+
if (type === "persona") {
|
|
623
|
+
withoutEmbedding.tools = buildPersonaToolsMap(
|
|
624
|
+
(withoutEmbedding.tools as string[] | undefined) ?? [],
|
|
625
|
+
state.tools ?? [],
|
|
626
|
+
state.providers ?? []
|
|
627
|
+
);
|
|
628
|
+
}
|
|
585
629
|
return { type, ...withoutEmbedding };
|
|
586
630
|
}
|