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
package/src/cli/README.md CHANGED
@@ -15,9 +15,12 @@ ei --recent # Most recently mentioned items (no query
15
15
  ei --persona "Beta" --recent # Most recently mentioned items Beta has learned
16
16
  ei --id <id> # Look up entity by ID — or fetch a message by FQ ID
17
17
  echo <id> | ei --id # Look up entity by ID from stdin
18
- ei --install # Wire Ei into Claude Code, Cursor, Codex, and OpenCode (MCP + hooks + persona plugin)
18
+ ei --install # Wire Ei into Claude Code, Cursor, Codex, and OpenCode (MCP + context hooks + skills (ei-curate, ei-persona, and future shipped skills) + persona plugin where supported)
19
19
  ei --sync # Pull latest state from remote sync server into state.backup.json (no TUI required)
20
20
  ei mcp # Start the Ei MCP stdio server (for Claude Code/Cursor/Codex)
21
+ ei create <type> --json '<json>' # Create a new entity (fact/topic/person/persona)
22
+ ei update <type> <id> --json '<json>' # Replace an entity by ID (fact/topic/person/quote/persona)
23
+ ei remove <type> <id> # Remove an entity by ID
21
24
  ```
22
25
 
23
26
  Type aliases: `fact`, `person`, `topic`, `quote`, `persona` all work (singular or plural).
@@ -109,6 +112,9 @@ The MCP server exposes these tools to Claude Code, Cursor, Codex, and OpenCode:
109
112
  | `ei_search` | Search across all five data types (facts, topics, people, quotes, personas). Supports `type`, `persona`, `source`, `recent`, `limit` filters. Start here. |
110
113
  | `ei_lookup` | Full-record lookup for any entity by ID — facts, topics, people, quotes, or personas. Use when you need complete details beyond the search summary. |
111
114
  | `ei_fetch_message` | Retrieve a specific message by fully-qualified ID with optional `before`/`after` context window. Use when a quote result has a `message_id` and you want the original conversation. Routes to the correct source automatically. |
115
+ | `ei_create` | Create a new entity (fact, topic, person, or persona). Pass a full JSON record matching the entity's schema. Validates 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. |
116
+ | `ei_update` | Replace an entity by ID. Full-record replacement — fetch first with `ei_lookup`, edit the fields you need to change, and pass the complete record back. Any omitted field is treated as absent, not "leave unchanged". Supports fact, topic, person, quote, and persona. |
117
+ | `ei_remove` | Permanently remove a fact, topic, person, or persona by ID. Use to drop bad extracted data that shouldn't be corrected, just deleted, or to delete a persona that's no longer needed. Not available for quotes. Reserved built-in personas ("ei", "emmet") can't be removed this way — use `ei_update` to set `is_archived: true` instead. |
112
118
 
113
119
  ### `ei_search` arguments
114
120
 
@@ -133,7 +139,55 @@ All search commands return arrays. Each result includes a `type` field.
133
139
 
134
140
  **Persona**: `{ type, id, display_name, short_description, model, base_prompt, traits[], topics[] }`
135
141
 
136
- **ID lookup** (`lookup: true`): single object (not an array) with the same shape.
142
+ **ID lookup** (`ei --id <id>` / `ei_lookup`): single object (not an array) with the same shape as above, plus a `linked_quotes` array for Fact, Topic, and Person records — quotes attributed to that entity, useful when auditing what was said about a person or topic. Persona ID lookups additionally get a `tools` field: the raw `tools` id array is replaced with a self-documenting `{ providerDisplayName: { toolDisplayName: boolean } }` map, so an agent can see exactly what's granted (and what else is grantable) without a separate lookup.
143
+
144
+ ## Memory Management
145
+
146
+ `ei create`, `ei update`, and `ei remove` let you correct Ei's knowledge base directly — from the CLI or via MCP tools in your coding agent.
147
+
148
+ ### Which types support which operations
149
+
150
+ | Type | create | update | remove |
151
+ |------|--------|--------|--------|
152
+ | fact | yes | yes | yes |
153
+ | topic | yes | yes | yes |
154
+ | person | yes | yes | yes |
155
+ | quote | — | yes | — |
156
+ | persona | yes | yes | yes |
157
+
158
+ Quotes are created only by Ei's extraction pipeline (verifiable-origin data). They can be updated to repoint `data_item_ids` after a split/merge or to fix mistranscribed text, but never created or removed via these commands.
159
+
160
+ Personas support all three operations too, but through a separate schema (`PersonaEntity`, not the fact/topic/person `DataItemBase` shape) — see the `ei create/update/remove persona` examples below and the `ei-persona` skill for guided authoring.
161
+
162
+ ### Update semantics: full replacement, not a patch
163
+
164
+ `ei update` replaces the entire record. Any field you omit is gone. The safe pattern:
165
+
166
+ ```sh
167
+ # 1. Fetch the current record
168
+ ei --id abc-123
169
+
170
+ # 2. Edit only the fields you want to change, then submit the whole thing
171
+ ei update fact abc-123 --json '{"name":"Field of Study","description":"Software Engineering / CS","sentiment":0,"validated_date":"2026-03-16T22:46:03.367Z"}'
172
+ ```
173
+
174
+ ### Examples (from `ei --help`)
175
+
176
+ ```sh
177
+ ei create fact --json '{"name":"Field of Study","description":"CS","sentiment":0,"validated_date":""}'
178
+ ei update fact abc-123 --json '{"name":"Field of Study","description":"Updated","sentiment":0,"validated_date":""}'
179
+ ei update quote <id> --json '{"data_item_ids":["person-b-id"], ...}' # Repoint a quote after splitting a bad merge (fetch the full record via 'ei --id <id>' first)
180
+ ei create persona --json '{"display_name":"Yoda","long_description":"Speaks in inverted syntax, wise and patient.","traits":[{"name":"Inverted speech","description":"Talks like Yoda","sentiment":0.7}],"topics":[]}'
181
+ ei update persona <id> --json '<full persona record from ei --id <id>, edited>'
182
+ ei remove persona abc-123 # Remove a persona (reserved personas like "ei"/"emmet" must be archived instead)
183
+ ei remove fact abc-123
184
+ ```
185
+
186
+ ### Corrections queue
187
+
188
+ Changes written by `ei create/update/remove` (and the MCP tools `ei_create/ei_update/ei_remove`) go through a corrections queue (`$EI_DATA_PATH/corrections.json`). If a live Ei instance (TUI or daemon) is running, the Processor drains this file on every runLoop tick and applies changes to the live StateManager — no TUI restart required. If nothing is running, the write applies straight to `state.json` instead of waiting for a TUI session that may not start for days.
189
+
190
+ For safe, agent-driven curation with verification guardrails, use the `ei-curate` skill (see [Shipped Skills](#shipped-skills) below).
137
191
 
138
192
  ## Quick Sync
139
193
 
@@ -146,3 +200,19 @@ ei --sync
146
200
  Pulls the latest state from your remote sync server and saves it to `state.backup.json`. Works the same credential hierarchy as the TUI: reads from `state.backup.json` first, falls back to `EI_SYNC_USERNAME` / `EI_SYNC_PASSPHRASE` environment variables if not present.
147
201
 
148
202
  Will abort (with a clear error) if `state.json` already exists on this machine — that means Ei has run here and a conflict resolution step would be needed on next launch.
203
+
204
+ ## Shipped Skills
205
+
206
+ `ei --install` copies Ei's shipped skills into each harness's skill discovery directory alongside the MCP config and context hooks:
207
+
208
+ - **Claude Code / OMP**: `~/.config/opencode/skills/<skill-name>/`
209
+ - Other tools: respective skill directories per harness
210
+
211
+ Skills are installed automatically — any directory added under `skills/` in the Ei package gets copied on the next `ei --install` run.
212
+
213
+ ### Currently shipped
214
+
215
+ | Skill | What it does |
216
+ |-------|-------------|
217
+ | `ei-curate` | Safe agent-driven memory curation. Provides verified workflows for fixing merged records, bad attributions, stale facts, and mis-attributed quotes — using `ei create/update/remove` with explicit confirmation before every write. Read the full workflow at `skills/ei-curate/SKILL.md`. Load it in your harness with `/ei-curate`. |
218
+ | `ei-persona` | Safe agent-driven persona authoring. Guides creating, editing (traits/topics/description), archiving, or deleting a persona's *character* via `ei create/update/remove persona` — distinct from `ei-curate`, which corrects learned data rather than authoring identity. Read the full workflow at `skills/ei-persona/SKILL.md`. Load it in your harness with `/ei-persona`. |
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Validated create/update/remove endpoints for ei_create, ei_update, and
3
+ * ei_remove — shared by the CLI subcommands (src/cli.ts) and the MCP tools
4
+ * (src/cli/mcp.ts) so both surfaces enforce identical schemas and produce
5
+ * identical CorrectionRecords.
6
+ *
7
+ * Every accepted entity is fully materialized here — id assigned (create),
8
+ * embedding computed, Person identifiers sanitized and name-synced — before
9
+ * being handed to writeCorrection(). Callers of ei_update must round-trip
10
+ * a full record obtained from ei_lookup: unset optional fields are treated
11
+ * as absent, not "leave existing value alone" (full-replacement semantics —
12
+ * see design discussion for why partial-field patching isn't safe here).
13
+ */
14
+
15
+ import { z } from "zod";
16
+ import { loadLatestState } from "./retrieval.js";
17
+ import { writeCorrection } from "./corrections-writer.js";
18
+ import { sanitizeEiPersonaIdentifiers } from "../core/utils/identifier-utils.js";
19
+ import { computeDataItemEmbedding, computeQuoteEmbedding } from "../core/embedding-service.js";
20
+ import type { CorrectableType, CorrectionRecord } from "../core/corrections.js";
21
+ import type { Fact, Topic, Person, Quote } from "../core/types.js";
22
+ import type { PersonaEntity } from "../core/types/entities.js";
23
+
24
+ // Fact/topic/person handling below is keyed by this narrower alias, not the
25
+ // full CorrectableType — parseInput/buildAndWriteUpsert only ever validate
26
+ // and materialize these 3 types. Quotes get their own updateQuoteEntity path
27
+ // (different embedding source, no last_updated field, update-only by design)
28
+ // rather than flowing through this shared machinery.
29
+ type NonQuoteType = "fact" | "topic" | "person";
30
+
31
+ export const CORRECTABLE_TYPES: CorrectableType[] = ["fact", "topic", "person"];
32
+ // Wider set accepted by the `update` surface only — a Quote can be corrected
33
+ // (data_item_ids repointed after a split/merge, mistranscribed text fixed)
34
+ // but, per design, never created or removed, so create/remove keep gating on
35
+ // CORRECTABLE_TYPES above while update alone widens to this constant.
36
+ export const UPDATABLE_TYPES: CorrectableType[] = ["fact", "topic", "person", "quote"];
37
+
38
+ // Metadata fields common to all three entity types, all server-preserved
39
+ // pass-through — a caller round-tripping ei_lookup output keeps them
40
+ // unless they deliberately edit/clear them.
41
+ const metaFields = {
42
+ learned_on: z.string().optional(),
43
+ last_mentioned: z.string().optional(),
44
+ learned_by: z.string().optional(),
45
+ last_changed_by: z.string().optional(),
46
+ interested_personas: z.array(z.string()).optional(),
47
+ sources: z.array(z.string()).optional(),
48
+ persona_groups: z.array(z.string()).optional(),
49
+ rewrite_length_floor: z.number().optional(),
50
+ };
51
+
52
+ const factSchema = z.strictObject({
53
+ name: z.string().min(1),
54
+ description: z.string(),
55
+ sentiment: z.number().min(-1).max(1),
56
+ validated_date: z.string(),
57
+ ...metaFields,
58
+ });
59
+
60
+ const topicSchema = z.strictObject({
61
+ name: z.string().min(1),
62
+ description: z.string(),
63
+ sentiment: z.number().min(-1).max(1),
64
+ category: z.string().optional(),
65
+ exposure_current: z.number().min(0).max(1).default(0),
66
+ exposure_desired: z.number().min(0).max(1).default(0.5),
67
+ last_ei_asked: z.string().nullable().optional(),
68
+ ...metaFields,
69
+ });
70
+
71
+ const identifierSchema = z.strictObject({
72
+ type: z.string().min(1),
73
+ value: z.string().min(1),
74
+ is_primary: z.boolean().optional(),
75
+ });
76
+
77
+ const personSchema = z.strictObject({
78
+ name: z.string().optional(),
79
+ description: z.string(),
80
+ sentiment: z.number().min(-1).max(1),
81
+ identifiers: z.array(identifierSchema).optional(),
82
+ validated_date: z.string().optional(),
83
+ relationship: z.string().default(""),
84
+ exposure_current: z.number().min(0).max(1).default(0),
85
+ exposure_desired: z.number().min(0).max(1).default(0.5),
86
+ last_ei_asked: z.string().nullable().optional(),
87
+ ...metaFields,
88
+ }).refine(
89
+ (p) => (p.identifiers && p.identifiers.length > 0) || (p.name && p.name.length > 0),
90
+ { message: "Person requires at least one identifier or a name" }
91
+ );
92
+
93
+ const quoteSchema = z.strictObject({
94
+ message_id: z.string().nullable(),
95
+ data_item_ids: z.array(z.string()),
96
+ persona_groups: z.array(z.string()),
97
+ text: z.string().min(1),
98
+ speaker: z.string().min(1),
99
+ channel: z.string().optional(),
100
+ timestamp: z.string(),
101
+ start: z.number().nullable(),
102
+ end: z.number().nullable(),
103
+ created_at: z.string(),
104
+ created_by: z.enum(["extraction", "human"]),
105
+ });
106
+ export type QuoteInput = z.infer<typeof quoteSchema>;
107
+
108
+ const SCHEMAS = { fact: factSchema, topic: topicSchema, person: personSchema } as const;
109
+
110
+ export type FactInput = z.infer<typeof factSchema>;
111
+ export type TopicInput = z.infer<typeof topicSchema>;
112
+ export type PersonInput = z.infer<typeof personSchema>;
113
+
114
+ /** Thrown on schema violations — callers should surface `.message` directly, not swallow it. */
115
+ export class CorrectionValidationError extends Error {}
116
+
117
+ /**
118
+ * Strip the embedding vector before handing a just-written record back to a
119
+ * caller. writeCorrection() needs the real embedding (computed above) to
120
+ * persist for search — this only shapes the CLI/MCP *response*, mirroring
121
+ * lookupById's read-path convention (retrieval.ts) of never surfacing a raw
122
+ * float array to a consumer that has no use for it. Safe to strip after the
123
+ * fact: writeCorrection() has already awaited and fully persisted by the
124
+ * time each call site below runs this.
125
+ */
126
+ function stripEmbedding<T extends { embedding?: number[] }>(record: T): T {
127
+ const { embedding, ...rest } = record;
128
+ void embedding;
129
+ return rest as T;
130
+ }
131
+
132
+ /**
133
+ * Server-owned fields that ei_lookup returns on every entity and that a
134
+ * caller following the documented ei_lookup -> edit -> ei_update round-trip
135
+ * will send straight back. Stripped before schema validation so the
136
+ * round-trip actually works — id/last_updated are always server-assigned
137
+ * (the id param and a fresh timestamp win, never a caller-supplied value),
138
+ * `type` is `lookupById`'s own discriminator, never a real entity field,
139
+ * and `linked_quotes` is a read-only reverse-index (which quotes reference
140
+ * this entity) that lookupById computes on the fly for fact/topic/person —
141
+ * never a field a caller is meant to set. Any OTHER unknown key still fails
142
+ * validation — this is a narrow allowlist, not permissive parsing.
143
+ */
144
+ const ROUND_TRIP_FIELDS = ["id", "type", "last_updated", "linked_quotes"] as const;
145
+
146
+ function parseInput(entityType: NonQuoteType, body: unknown, mode: "create" | "update"): FactInput | TopicInput | PersonInput {
147
+ let input: unknown = body;
148
+ if (mode === "update" && body && typeof body === "object") {
149
+ const stripped: Record<string, unknown> = { ...body };
150
+ for (const field of ROUND_TRIP_FIELDS) {
151
+ delete stripped[field];
152
+ }
153
+ input = stripped;
154
+ }
155
+ const result = SCHEMAS[entityType].safeParse(input);
156
+ if (!result.success) {
157
+ throw new CorrectionValidationError(
158
+ `Invalid ${entityType}: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
159
+ );
160
+ }
161
+ return result.data;
162
+ }
163
+
164
+ /**
165
+ * Materialize a validated input into a full entity record (id, name-synced
166
+ * identifiers, embedding) and persist it as an upsert correction. Shared by
167
+ * createEntity (fresh id) and updateEntity (caller-supplied id) — both
168
+ * need identical Person identifier sanitization and embedding computation.
169
+ */
170
+ async function buildAndWriteUpsert(
171
+ entityType: NonQuoteType,
172
+ id: string,
173
+ parsed: FactInput | TopicInput | PersonInput,
174
+ personas: PersonaEntity[]
175
+ ): Promise<Fact | Topic | Person> {
176
+ const now = new Date().toISOString();
177
+
178
+ let record: Fact | Topic | Person;
179
+ if (entityType === "person") {
180
+ const input = parsed as PersonInput;
181
+ const identifiers = sanitizeEiPersonaIdentifiers(input.identifiers ?? [], personas);
182
+ const primary = identifiers.find((i) => i.is_primary) ?? identifiers[0];
183
+ const name = primary?.value ?? input.name ?? "";
184
+ record = { ...input, id, identifiers, name, last_updated: now } as Person;
185
+ } else {
186
+ record = { ...parsed, id, last_updated: now } as Fact | Topic;
187
+ }
188
+
189
+ record.embedding = await computeDataItemEmbedding(record);
190
+
191
+ const correction: CorrectionRecord = { op: "upsert", entity_type: entityType, id, record, timestamp: now };
192
+ await writeCorrection(correction);
193
+ return record;
194
+ }
195
+
196
+ export async function createEntity(
197
+ entityType: CorrectableType,
198
+ body: unknown
199
+ ): Promise<{ id: string; record: Fact | Topic | Person }> {
200
+ const parsed = parseInput(entityType as NonQuoteType, body, "create");
201
+ const state = await loadLatestState();
202
+ if (!state) {
203
+ throw new Error("No saved state found. Is EI_DATA_PATH set correctly?");
204
+ }
205
+
206
+ const id = crypto.randomUUID();
207
+ const record = await buildAndWriteUpsert(entityType as NonQuoteType, id, parsed, Object.values(state.personas).map((p) => p.entity));
208
+ return { id, record: stripEmbedding(record) };
209
+ }
210
+
211
+ export async function updateEntity(
212
+ entityType: CorrectableType,
213
+ id: string,
214
+ body: unknown
215
+ ): Promise<Fact | Topic | Person | Quote> {
216
+ if (entityType === "quote") {
217
+ return updateQuoteEntity(id, body);
218
+ }
219
+
220
+ const parsed = parseInput(entityType, body, "update");
221
+ const state = await loadLatestState();
222
+ if (!state) {
223
+ throw new Error("No saved state found. Is EI_DATA_PATH set correctly?");
224
+ }
225
+
226
+ const array = entityType === "fact" ? state.human.facts : entityType === "topic" ? state.human.topics : state.human.people;
227
+ if (!array.some((item: { id: string }) => item.id === id)) {
228
+ throw new Error(`No ${entityType} found with id: ${id}`);
229
+ }
230
+
231
+ return stripEmbedding(await buildAndWriteUpsert(entityType, id, parsed, Object.values(state.personas).map((p) => p.entity)));
232
+ }
233
+
234
+ /**
235
+ * Quotes skip the shared fact/topic/person machinery entirely: their
236
+ * embedding is derived from `text` (not name+description, so
237
+ * computeDataItemEmbedding doesn't apply), they have no `last_updated`
238
+ * field to stamp, and — per design — this is their ONLY correction path;
239
+ * there is no createQuoteEntity/removeQuoteEntity to pair it with.
240
+ */
241
+ async function updateQuoteEntity(id: string, body: unknown): Promise<Quote> {
242
+ let input: unknown = body;
243
+ if (body && typeof body === "object") {
244
+ const stripped: Record<string, unknown> = { ...body };
245
+ for (const field of ROUND_TRIP_FIELDS) delete stripped[field];
246
+ input = stripped;
247
+ }
248
+ const result = quoteSchema.safeParse(input);
249
+ if (!result.success) {
250
+ throw new CorrectionValidationError(
251
+ `Invalid quote: ${result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ")}`
252
+ );
253
+ }
254
+ const state = await loadLatestState();
255
+ if (!state) {
256
+ throw new Error("No saved state found. Is EI_DATA_PATH set correctly?");
257
+ }
258
+ if (!state.human.quotes.some((q) => q.id === id)) {
259
+ throw new Error(`No quote found with id: ${id}`);
260
+ }
261
+ // Beta's review (I1): the schema above only checks data_item_ids is an
262
+ // array of strings, not that each string resolves to a real link target.
263
+ // Without this, a typo'd or stale ID in an external quote repoint — the
264
+ // primary consumer is the person-split/merge repair workflow — would
265
+ // silently store a dangling link instead of failing loudly.
266
+ const validLinkIds = new Set([
267
+ ...state.human.facts.map((f) => f.id),
268
+ ...state.human.topics.map((t) => t.id),
269
+ ...state.human.people.map((p) => p.id),
270
+ ]);
271
+ const invalidIds = result.data.data_item_ids.filter((itemId) => !validLinkIds.has(itemId));
272
+ if (invalidIds.length > 0) {
273
+ throw new CorrectionValidationError(
274
+ `Invalid quote: data_item_ids references unknown or disallowed entities: ${invalidIds.join(", ")} (must resolve to an existing fact, topic, or person — not a quote, persona, or unmatched ID)`
275
+ );
276
+ }
277
+ const record: Quote = { ...result.data, id };
278
+ record.embedding = await computeQuoteEmbedding(record.text);
279
+ const correction: CorrectionRecord = { op: "upsert", entity_type: "quote", id, record, timestamp: new Date().toISOString() };
280
+ await writeCorrection(correction);
281
+ return stripEmbedding(record);
282
+ }
283
+
284
+ export async function removeEntity(entityType: CorrectableType, id: string): Promise<void> {
285
+ const state = await loadLatestState();
286
+ if (!state) {
287
+ throw new Error("No saved state found. Is EI_DATA_PATH set correctly?");
288
+ }
289
+
290
+ const array = entityType === "fact" ? state.human.facts : entityType === "topic" ? state.human.topics : state.human.people;
291
+ if (!array.some((item: { id: string }) => item.id === id)) {
292
+ throw new Error(`No ${entityType} found with id: ${id}`);
293
+ }
294
+
295
+ const correction: CorrectionRecord = { op: "remove", entity_type: entityType, id, timestamp: new Date().toISOString() };
296
+ await writeCorrection(correction);
297
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * CLI-side write path for ei_update / ei_create / ei_remove.
3
+ *
4
+ * Every correction is appended to corrections.json under lock. If a live
5
+ * Ei instance (TUI or future daemon, #76) holds ei.lock, that's the only
6
+ * thing this module does — the running Processor drains corrections.json
7
+ * on its own runLoop tick (~100ms).
8
+ *
9
+ * If no live instance holds the lock, this module self-drains directly
10
+ * into state.json instead of leaving the correction to wait indefinitely
11
+ * for a TUI session that may not start for days. This is safe specifically
12
+ * because "no live instance" means nothing holds an in-memory StateManager
13
+ * that could later overwrite the write with a stale copy — the hazard
14
+ * corrections.json exists to avoid only applies while an instance is live.
15
+ *
16
+ * Sync users are a distinct case from "no state.json": on clean TUI exit,
17
+ * state.json is renamed to state.backup.json and pushed to the remote
18
+ * store (see moveToBackup() in tui/src/storage/file.ts and the ceremony
19
+ * around RemoteSync). A missing state.json with a present state.backup.json
20
+ * is NOT an error — it means the next TUI launch will pull remote state
21
+ * fresh, and self-draining into a hand-rolled state.json here would create
22
+ * a conflicting local state the next launch has to reconcile. In that case
23
+ * the correction queues in corrections.json and waits for the next TUI
24
+ * session, same as if a live instance's runLoop just hadn't ticked yet.
25
+ */
26
+
27
+ import { join } from "path";
28
+ import { readFile, access } from "fs/promises";
29
+ import { getDataPath } from "./retrieval.js";
30
+ import { withLock, atomicWrite } from "../storage/file-lock.js";
31
+ import { appendCorrection, readCorrections, applyCorrectionsToState } from "../core/corrections.js";
32
+ import type { CorrectionRecord } from "../core/corrections.js";
33
+ import { encodeAllEmbeddings, decodeAllEmbeddings } from "../storage/embeddings.js";
34
+ import type { StorageState } from "../core/types.js";
35
+
36
+ const STATE_FILE = "state.json";
37
+ const BACKUP_FILE = "state.backup.json";
38
+ const LOCK_FILE = "ei.lock";
39
+ const CORRECTIONS_FILE = "corrections.json";
40
+
41
+ export function getCorrectionsPath(): string {
42
+ return join(getDataPath(), CORRECTIONS_FILE);
43
+ }
44
+
45
+ async function pathExists(path: string): Promise<boolean> {
46
+ try {
47
+ await access(path);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ /** True if a live Ei instance (TUI or daemon) currently holds ei.lock. */
55
+ async function isLiveInstanceRunning(): Promise<boolean> {
56
+ const lockPath = join(getDataPath(), LOCK_FILE);
57
+ let lockText: string;
58
+ try {
59
+ lockText = await readFile(lockPath, "utf-8");
60
+ } catch {
61
+ return false;
62
+ }
63
+ let lock: { pid: number };
64
+ try {
65
+ lock = JSON.parse(lockText) as { pid: number };
66
+ } catch {
67
+ return false;
68
+ }
69
+ try {
70
+ process.kill(lock.pid, 0);
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Apply a correction. Appends to corrections.json for a live-drained
79
+ * pickup, or self-drains straight into state.json when nothing is running
80
+ * to pick it up. Throws if neither state.json nor state.backup.json exist
81
+ * (no Ei data at this EI_DATA_PATH — misconfiguration, not a sync gap).
82
+ */
83
+ export async function writeCorrection(record: CorrectionRecord): Promise<void> {
84
+ const dataPath = getDataPath();
85
+ const correctionsPath = join(dataPath, CORRECTIONS_FILE);
86
+
87
+ if (await isLiveInstanceRunning()) {
88
+ await appendCorrection(correctionsPath, record);
89
+ return;
90
+ }
91
+
92
+ const statePath = join(dataPath, STATE_FILE);
93
+ const stateExists = await pathExists(statePath);
94
+
95
+ if (!stateExists) {
96
+ const backupExists = await pathExists(join(dataPath, BACKUP_FILE));
97
+ if (!backupExists) {
98
+ throw new Error(`No Ei data found at ${dataPath}. Is EI_DATA_PATH set correctly?`);
99
+ }
100
+ // Sync user, TUI currently closed — state.json will reappear on next
101
+ // TUI launch (pulled from remote). Queue for that drain instead of
102
+ // fabricating a local state.json that would conflict with the pull.
103
+ await appendCorrection(correctionsPath, record);
104
+ return;
105
+ }
106
+
107
+ // No live instance and state.json exists — safe to self-drain directly.
108
+ // Re-check liveness after acquiring the lock to shrink the race window
109
+ // where a TUI/daemon starts between the check above and the write below.
110
+ await withLock(statePath, async () => {
111
+ if (await isLiveInstanceRunning()) {
112
+ await appendCorrection(correctionsPath, record);
113
+ return;
114
+ }
115
+
116
+ // Read pending corrections, apply them (plus the new record), and clear
117
+ // the queue — all under correctionsPath's own lock, not just statePath's.
118
+ // Locking only statePath here left a window where a concurrent
119
+ // appendCorrection() (which locks correctionsPath, not statePath) could
120
+ // write a new record between our unlocked read and our unconditional
121
+ // "[]" clear, silently discarding it. Nesting the correctionsPath lock
122
+ // inside statePath's serializes against every other writer of that file.
123
+ await withLock(correctionsPath, async () => {
124
+ const text = await readFile(statePath, "utf-8");
125
+ const state = decodeAllEmbeddings(JSON.parse(text) as StorageState);
126
+
127
+ const pending = await readCorrections(correctionsPath);
128
+ applyCorrectionsToState(state, [...pending, record]);
129
+ state.timestamp = new Date().toISOString();
130
+
131
+ // State write happens before the queue clear: if we crash in between,
132
+ // the next run still sees the pending records in corrections.json and
133
+ // safely re-applies them (upsert/remove are idempotent by id).
134
+ await atomicWrite(statePath, JSON.stringify(encodeAllEmbeddings(state), null, 2));
135
+ await atomicWrite(correctionsPath, "[]");
136
+ });
137
+ });
138
+ }