ei-tui 1.6.9 → 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.
Files changed (37) hide show
  1. package/README.md +4 -0
  2. package/package.json +2 -1
  3. package/skills/coding-harness-reflect/SKILL.md +230 -0
  4. package/skills/ei-curate/SKILL.md +174 -0
  5. package/skills/ei-curate/references/cli.md +160 -0
  6. package/skills/ei-curate/references/provenance.md +88 -0
  7. package/skills/ei-curate/references/recipes.md +144 -0
  8. package/skills/ei-curate/references/talking-to-the-user.md +71 -0
  9. package/skills/ei-persona/SKILL.md +238 -0
  10. package/skills/ei-persona/references/cli.md +265 -0
  11. package/skills/ei-persona/references/recipes.md +247 -0
  12. package/skills/ei-persona/references/talking-to-the-user.md +107 -0
  13. package/src/cli/README.md +72 -2
  14. package/src/cli/corrections-endpoints.ts +297 -0
  15. package/src/cli/corrections-writer.ts +138 -0
  16. package/src/cli/install.ts +117 -25
  17. package/src/cli/mcp.ts +80 -1
  18. package/src/cli/persona-corrections.ts +442 -0
  19. package/src/cli/retrieval.ts +46 -2
  20. package/src/cli.ts +131 -1
  21. package/src/core/corrections.ts +233 -0
  22. package/src/core/handlers/human-matching.ts +2 -2
  23. package/src/core/persona-tools.ts +92 -0
  24. package/src/core/processor.ts +113 -1
  25. package/src/core/state/human.ts +10 -0
  26. package/src/core/state/personas.ts +8 -0
  27. package/src/core/state-manager.ts +11 -0
  28. package/src/core/utils/identifier-utils.ts +3 -2
  29. package/src/storage/file-lock.ts +120 -0
  30. package/tui/README.md +2 -0
  31. package/tui/src/commands/provider.tsx +1 -1
  32. package/tui/src/components/WelcomeOverlay.tsx +3 -3
  33. package/tui/src/context/ei.tsx +14 -0
  34. package/tui/src/index.tsx +13 -1
  35. package/tui/src/storage/file.ts +15 -83
  36. package/tui/src/util/instance-lock.ts +3 -2
  37. 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
+ }
@@ -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) return decodeAllEmbeddings(JSON.parse(text) as StorageState);
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
- return null;
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
  }