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/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** (`
|
|
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`. |
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadLatestState, retrievePersonas, retrievePersonasSemantic } from "../retrieval";
|
|
1
|
+
import { loadLatestState, retrievePersonas, retrievePersonasSemantic, mapPersona } from "../retrieval";
|
|
2
2
|
import { getEmbeddingService } from "../../core/embedding-service";
|
|
3
3
|
import type { PersonaResult } from "../retrieval";
|
|
4
4
|
|
|
@@ -14,8 +14,53 @@ export async function execute(query: string, limit: number, options: { recent?:
|
|
|
14
14
|
return nameResults;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
// BUG-2 fix: query may be longer than the stored persona name
|
|
18
|
+
// (e.g. "Beta — QA Goddess" vs stored "Beta"). Try reverse containment
|
|
19
|
+
// before falling to semantic search, which requires an embedding.
|
|
20
|
+
const queryLower = query.toLowerCase();
|
|
21
|
+
const reverseResults = Object.values(state.personas)
|
|
22
|
+
.map((p) => p.entity)
|
|
23
|
+
.filter((p) => queryLower.includes(p.display_name.toLowerCase()))
|
|
24
|
+
.slice(0, limit)
|
|
25
|
+
.map(mapPersona);
|
|
26
|
+
if (reverseResults.length > 0) {
|
|
27
|
+
return reverseResults;
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
const embeddingService = getEmbeddingService();
|
|
18
31
|
const queryVector = await embeddingService.embed(query);
|
|
19
32
|
const semanticResults = await retrievePersonasSemantic(queryVector, state, limit);
|
|
20
33
|
return semanticResults;
|
|
21
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Format a PersonaResult as a <ei-relationship> block for injection into
|
|
38
|
+
* AI system prompts. Equivalent to the jq formatter in .zshenv.omp and
|
|
39
|
+
* the inline builder previously in the OpenCode plugin — consolidated here
|
|
40
|
+
* so all integrations can call `ei personas <name> --format prompt`.
|
|
41
|
+
*/
|
|
42
|
+
export function buildEiRelationshipBlock(persona: PersonaResult): string {
|
|
43
|
+
const strongTraits = (persona.traits ?? [])
|
|
44
|
+
.filter((t) => t.strength >= 0.7)
|
|
45
|
+
.sort((a, b) => b.strength - a.strength)
|
|
46
|
+
.map((t) => `**${t.name}** (${Math.round(t.strength * 100)}%): ${t.description}`)
|
|
47
|
+
.join("\n");
|
|
48
|
+
const sortedTopics = [...(persona.topics ?? [])]
|
|
49
|
+
.sort((a, b) => b.exposure_current - a.exposure_current)
|
|
50
|
+
.map((t) => `**${t.name}**: ${t.perspective} — ${t.approach}`)
|
|
51
|
+
.join("\n");
|
|
52
|
+
return [
|
|
53
|
+
"<!-- ei-relationship-injected -->",
|
|
54
|
+
"<ei-relationship>",
|
|
55
|
+
"## Ei: Relationship Context",
|
|
56
|
+
"",
|
|
57
|
+
persona.base_prompt ?? "",
|
|
58
|
+
"",
|
|
59
|
+
"### Working Style",
|
|
60
|
+
strongTraits || "(no traits above threshold)",
|
|
61
|
+
"",
|
|
62
|
+
"### Shared Context",
|
|
63
|
+
sortedTopics || "(no topics)",
|
|
64
|
+
"</ei-relationship>",
|
|
65
|
+
].join("\n");
|
|
66
|
+
}
|
|
@@ -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
|
+
}
|