ei-tui 1.6.9 → 1.7.1
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/corrections-endpoints.ts +297 -0
- package/src/cli/corrections-writer.ts +138 -0
- package/src/cli/install.ts +117 -25
- 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 +131 -1
- package/src/core/corrections.ts +233 -0
- package/src/core/handlers/human-extraction.ts +64 -15
- package/src/core/handlers/human-matching.ts +2 -2
- package/src/core/orchestrators/human-extraction.ts +1 -11
- package/src/core/persona-manager.ts +18 -7
- package/src/core/persona-tools.ts +92 -0
- 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/utils/identifier-utils.ts +3 -2
- package/src/prompts/human/person-scan.ts +2 -0
- package/src/prompts/human/person-update.ts +14 -3
- 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/yaml-persona.ts +7 -38
|
@@ -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
|
}
|