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.
- package/README.md +4 -0
- package/package.json +2 -1
- package/skills/coding-harness-reflect/SKILL.md +230 -0
- package/skills/ei-curate/SKILL.md +174 -0
- package/skills/ei-curate/references/cli.md +160 -0
- package/skills/ei-curate/references/provenance.md +88 -0
- package/skills/ei-curate/references/recipes.md +144 -0
- package/skills/ei-curate/references/talking-to-the-user.md +71 -0
- package/skills/ei-persona/SKILL.md +238 -0
- package/skills/ei-persona/references/cli.md +265 -0
- package/skills/ei-persona/references/recipes.md +247 -0
- package/skills/ei-persona/references/talking-to-the-user.md +107 -0
- package/src/cli/README.md +72 -2
- package/src/cli/corrections-endpoints.ts +297 -0
- package/src/cli/corrections-writer.ts +138 -0
- package/src/cli/install.ts +117 -25
- package/src/cli/mcp.ts +80 -1
- package/src/cli/persona-corrections.ts +442 -0
- package/src/cli/retrieval.ts +46 -2
- package/src/cli.ts +131 -1
- package/src/core/corrections.ts +233 -0
- package/src/core/handlers/human-matching.ts +2 -2
- package/src/core/persona-tools.ts +92 -0
- package/src/core/processor.ts +113 -1
- package/src/core/state/human.ts +10 -0
- package/src/core/state/personas.ts +8 -0
- package/src/core/state-manager.ts +11 -0
- package/src/core/utils/identifier-utils.ts +3 -2
- package/src/storage/file-lock.ts +120 -0
- package/tui/README.md +2 -0
- package/tui/src/commands/provider.tsx +1 -1
- package/tui/src/components/WelcomeOverlay.tsx +3 -3
- package/tui/src/context/ei.tsx +14 -0
- package/tui/src/index.tsx +13 -1
- package/tui/src/storage/file.ts +15 -83
- package/tui/src/util/instance-lock.ts +3 -2
- package/tui/src/util/yaml-persona.ts +7 -38
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,9 @@ import type { StorageState } from "./core/types";
|
|
|
17
17
|
import { resolvePersonaId, filterByPersona, filterTypeSpecificByPersona, filterBySource, filterTypeSpecificBySource } from "./cli/persona-filter.js";
|
|
18
18
|
import { installMcpClients } from "./cli/install.js";
|
|
19
19
|
import { getRecentSessionMessages } from "./cli/session-context.js";
|
|
20
|
+
import { createEntity, updateEntity, removeEntity, CorrectionValidationError, CORRECTABLE_TYPES, UPDATABLE_TYPES } from "./cli/corrections-endpoints.js";
|
|
21
|
+
import { createPersonaEntity, updatePersonaEntity, removePersonaEntity } from "./cli/persona-corrections.js";
|
|
22
|
+
import type { CorrectableType } from "./core/corrections.js";
|
|
20
23
|
import pkg from "../package.json" assert { type: "json" };
|
|
21
24
|
|
|
22
25
|
const rawArgs = process.argv.slice(2);
|
|
@@ -38,6 +41,39 @@ const TYPE_ALIASES: Record<string, string> = {
|
|
|
38
41
|
personas: "personas",
|
|
39
42
|
};
|
|
40
43
|
|
|
44
|
+
// Wider set accepted by `ei create/update/remove persona` — corrections-endpoints.ts's
|
|
45
|
+
// CORRECTABLE_TYPES/UPDATABLE_TYPES intentionally stay persona-free (personas bypass
|
|
46
|
+
// that module's shared SCHEMAS dispatch entirely, see persona-corrections.ts), so this
|
|
47
|
+
// CLI-only layer is what actually widens the accepted type set.
|
|
48
|
+
const CLI_CORRECTABLE_TYPES = [...CORRECTABLE_TYPES, "persona"] as const;
|
|
49
|
+
const CLI_UPDATABLE_TYPES = [...UPDATABLE_TYPES, "persona"] as const;
|
|
50
|
+
|
|
51
|
+
// Singular CorrectableType resolution for `ei create/update/remove` — derived
|
|
52
|
+
// from TYPE_ALIASES + CLI_CORRECTABLE_TYPES so the two alias systems can never
|
|
53
|
+
// silently diverge on which strings are accepted (e.g. "person"/"people"
|
|
54
|
+
// stay synonymous here too, since both funnel through TYPE_ALIASES first).
|
|
55
|
+
const PLURAL_TO_CORRECTABLE: Record<string, CorrectableType> = Object.fromEntries(
|
|
56
|
+
CLI_CORRECTABLE_TYPES.map((t) => [TYPE_ALIASES[t], t])
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
function resolveCorrectableType(raw: string): CorrectableType | null {
|
|
60
|
+
const plural = TYPE_ALIASES[raw];
|
|
61
|
+
return plural ? PLURAL_TO_CORRECTABLE[plural] ?? null : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Plural CorrectableType resolution for `ei update` — same TYPE_ALIASES
|
|
65
|
+
// lookup as resolveCorrectableType above, but sourced from CLI_UPDATABLE_TYPES
|
|
66
|
+
// instead of CLI_CORRECTABLE_TYPES since quotes are correctable via update
|
|
67
|
+
// (repointing data_item_ids after a split/merge, fixing mistranscribed
|
|
68
|
+
// text) but never created or removed.
|
|
69
|
+
const PLURAL_TO_UPDATABLE: Record<string, CorrectableType> = Object.fromEntries(
|
|
70
|
+
CLI_UPDATABLE_TYPES.map((t) => [TYPE_ALIASES[t], t])
|
|
71
|
+
);
|
|
72
|
+
function resolveUpdatableType(raw: string): CorrectableType | null {
|
|
73
|
+
const plural = TYPE_ALIASES[raw];
|
|
74
|
+
return plural ? PLURAL_TO_UPDATABLE[plural] ?? null : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
41
77
|
function printHelp(): void {
|
|
42
78
|
console.log(`
|
|
43
79
|
Ei
|
|
@@ -55,6 +91,9 @@ Usage:
|
|
|
55
91
|
ei --id <id> Look up a specific entity by ID
|
|
56
92
|
echo <id> | ei --id Look up entity by ID from stdin
|
|
57
93
|
ei mcp Start the Ei MCP stdio server (for Claude Code/Cursor/Codex)
|
|
94
|
+
ei create <type> --json '<json>' Create a new entity (fact/topic/person/persona)
|
|
95
|
+
ei update <type> <id> --json '<json>' Replace an entity by ID (full record, not a patch; fact/topic/person/quote/persona)
|
|
96
|
+
ei remove <type> <id> Remove an entity by ID (fact/topic/person/persona; not quotes)
|
|
58
97
|
|
|
59
98
|
Types:
|
|
60
99
|
quote / quotes Quotes from conversation history
|
|
@@ -69,12 +108,13 @@ Options:
|
|
|
69
108
|
--persona, -p Filter to entities a specific persona has learned about
|
|
70
109
|
--source, -s Filter to entities from a specific source (prefix match, e.g. "cursor", "codex:my-machine", "opencode:my-machine:ses_abc123")
|
|
71
110
|
--id Look up entity by ID (accepts value or stdin)
|
|
72
|
-
--install Register Ei with Claude Code, Cursor, Codex, and OpenCode (MCP + context hooks where supported)
|
|
111
|
+
--install Register Ei with Claude Code, Cursor, Codex, and OpenCode (MCP + context hooks + skills where supported)
|
|
73
112
|
--sync Pull latest state from remote sync server into state.backup.json (no TUI required)
|
|
74
113
|
--session <id> Session ID to enrich the query with recent context (use with --hook-source)
|
|
75
114
|
--hook-source <src> Source of the hook: "opencode-plugin" (OpenCode SQLite), "cursor", or "codex"
|
|
76
115
|
--transcript <path> Path to a Claude Code JSONL transcript file for context enrichment
|
|
77
116
|
--help, -h Show this help message
|
|
117
|
+
--json <json> JSON body for create/update (full record for update, not a patch)
|
|
78
118
|
|
|
79
119
|
Examples:
|
|
80
120
|
ei "debugging" # Search everything
|
|
@@ -86,6 +126,13 @@ Examples:
|
|
|
86
126
|
ei topics --source cursor "X" # Topics learned from Cursor sessions
|
|
87
127
|
ei --id abc-123 # Look up entity by ID
|
|
88
128
|
ei "memory leak" | jq .[0].id | ei --id # Pipe ID from search
|
|
129
|
+
ei create fact --json '{"name":"Field of Study","description":"CS","sentiment":0,"validated_date":""}'
|
|
130
|
+
ei update fact abc-123 --json '{"name":"Field of Study","description":"Updated","sentiment":0,"validated_date":""}'
|
|
131
|
+
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)
|
|
132
|
+
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":[]}'
|
|
133
|
+
ei update persona <id> --json '<full persona record from ei --id <id>, edited>'
|
|
134
|
+
ei remove persona abc-123 # Remove a persona (reserved personas like "ei"/"emmet" must be archived instead)
|
|
135
|
+
ei remove fact abc-123 # Remove a fact by ID
|
|
89
136
|
`);
|
|
90
137
|
}
|
|
91
138
|
|
|
@@ -146,6 +193,89 @@ async function main(): Promise<void> {
|
|
|
146
193
|
process.exit(0);
|
|
147
194
|
}
|
|
148
195
|
|
|
196
|
+
if (args[0] === "create") {
|
|
197
|
+
const rawType = args[1];
|
|
198
|
+
const entityType = rawType ? resolveCorrectableType(rawType) : null;
|
|
199
|
+
if (!entityType) {
|
|
200
|
+
console.error(`ei create requires a valid type (${CLI_CORRECTABLE_TYPES.join(", ")}). Got: ${rawType ?? "(none)"}`);
|
|
201
|
+
process.exit(1);
|
|
202
|
+
}
|
|
203
|
+
const jsonIdx = args.indexOf("--json");
|
|
204
|
+
const jsonStr = jsonIdx !== -1 ? args[jsonIdx + 1] : undefined;
|
|
205
|
+
if (!jsonStr) {
|
|
206
|
+
console.error("ei create requires --json '<json>'");
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
let body: unknown;
|
|
210
|
+
try {
|
|
211
|
+
body = JSON.parse(jsonStr);
|
|
212
|
+
} catch (e) {
|
|
213
|
+
console.error(`Invalid JSON: ${(e as Error).message}`);
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
try {
|
|
217
|
+
const result = entityType === "persona" ? await createPersonaEntity(body) : await createEntity(entityType, body);
|
|
218
|
+
console.log(JSON.stringify(result, null, 2));
|
|
219
|
+
process.exit(0);
|
|
220
|
+
} catch (e) {
|
|
221
|
+
console.error(e instanceof CorrectionValidationError ? e.message : (e as Error).message);
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (args[0] === "update") {
|
|
227
|
+
const rawType = args[1];
|
|
228
|
+
const id = args[2];
|
|
229
|
+
const entityType = rawType ? resolveUpdatableType(rawType) : null;
|
|
230
|
+
if (!entityType || !id) {
|
|
231
|
+
console.error(`Usage: ei update <type> <id> --json '<json>' (types: ${CLI_UPDATABLE_TYPES.join(", ")})`);
|
|
232
|
+
process.exit(1);
|
|
233
|
+
}
|
|
234
|
+
const jsonIdx = args.indexOf("--json");
|
|
235
|
+
const jsonStr = jsonIdx !== -1 ? args[jsonIdx + 1] : undefined;
|
|
236
|
+
if (!jsonStr) {
|
|
237
|
+
console.error("ei update requires --json '<json>'");
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
let body: unknown;
|
|
241
|
+
try {
|
|
242
|
+
body = JSON.parse(jsonStr);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
console.error(`Invalid JSON: ${(e as Error).message}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
try {
|
|
248
|
+
const record = entityType === "persona" ? await updatePersonaEntity(id, body) : await updateEntity(entityType, id, body);
|
|
249
|
+
console.log(JSON.stringify(record, null, 2));
|
|
250
|
+
process.exit(0);
|
|
251
|
+
} catch (e) {
|
|
252
|
+
console.error(e instanceof CorrectionValidationError ? e.message : (e as Error).message);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (args[0] === "remove") {
|
|
258
|
+
const rawType = args[1];
|
|
259
|
+
const id = args[2];
|
|
260
|
+
const entityType = rawType ? resolveCorrectableType(rawType) : null;
|
|
261
|
+
if (!entityType || !id) {
|
|
262
|
+
console.error(`Usage: ei remove <type> <id> (types: ${CLI_CORRECTABLE_TYPES.join(", ")})`);
|
|
263
|
+
process.exit(1);
|
|
264
|
+
}
|
|
265
|
+
try {
|
|
266
|
+
if (entityType === "persona") {
|
|
267
|
+
await removePersonaEntity(id);
|
|
268
|
+
} else {
|
|
269
|
+
await removeEntity(entityType, id);
|
|
270
|
+
}
|
|
271
|
+
console.log(JSON.stringify({ removed: true, id }, null, 2));
|
|
272
|
+
process.exit(0);
|
|
273
|
+
} catch (e) {
|
|
274
|
+
console.error((e as Error).message);
|
|
275
|
+
process.exit(1);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
149
279
|
if (args[0] === "--sync") {
|
|
150
280
|
const { getDataPath } = await import("./cli/retrieval.js");
|
|
151
281
|
const { RemoteSync } = await import("./storage/remote.js");
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Corrections Queue — external write path for CLI/MCP tools.
|
|
3
|
+
*
|
|
4
|
+
* Ei's CLI and MCP server are read-only against a snapshot of state.json —
|
|
5
|
+
* they never hold the live StateManager the running TUI/daemon owns, so
|
|
6
|
+
* they can't safely mutate state.json directly (the running instance's
|
|
7
|
+
* next debounced save would silently clobber the write with its stale
|
|
8
|
+
* in-memory copy).
|
|
9
|
+
*
|
|
10
|
+
* corrections.json is the queue that closes this gap. External writers
|
|
11
|
+
* (ei_update / ei_create / ei_remove) append fully-formed CorrectionRecords
|
|
12
|
+
* here under an advisory lock. Two consumers drain it:
|
|
13
|
+
* - The running Processor, once per runLoop tick (near-instant, ~100ms).
|
|
14
|
+
* - The CLI itself, self-draining directly into state.json when it
|
|
15
|
+
* detects no live instance is running (see src/cli/corrections-io.ts).
|
|
16
|
+
*
|
|
17
|
+
* Every CorrectionRecord is a complete, ready-to-apply entity — including
|
|
18
|
+
* a pre-computed embedding and (for creates) a pre-assigned id — so
|
|
19
|
+
* draining is a pure apply, never a fetch-then-merge.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { HumanEntity, Fact, Topic, Person, Quote, PersonaEntity, StorageState } from "./types.js";
|
|
23
|
+
import { isReservedPersonaId } from "./types.js";
|
|
24
|
+
import { withLock, atomicWrite } from "../storage/file-lock.js";
|
|
25
|
+
|
|
26
|
+
export type CorrectableType = "fact" | "topic" | "person" | "quote" | "persona";
|
|
27
|
+
export type CorrectableEntity = Fact | Topic | Person | Quote | PersonaEntity;
|
|
28
|
+
export const CORRECTABLE_TYPES: CorrectableType[] = ["fact", "topic", "person", "quote", "persona"];
|
|
29
|
+
|
|
30
|
+
export interface CorrectionUpsert {
|
|
31
|
+
op: "upsert";
|
|
32
|
+
entity_type: CorrectableType;
|
|
33
|
+
id: string;
|
|
34
|
+
record: CorrectableEntity;
|
|
35
|
+
timestamp: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface CorrectionRemove {
|
|
39
|
+
op: "remove";
|
|
40
|
+
entity_type: CorrectableType;
|
|
41
|
+
id: string;
|
|
42
|
+
timestamp: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type CorrectionRecord = CorrectionUpsert | CorrectionRemove;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Runtime shape validation for a CorrectionRecord read back from
|
|
49
|
+
* corrections.json. The TypeScript union is compile-time only — every
|
|
50
|
+
* record actually enters the system via `JSON.parse(...) as CorrectionRecord`
|
|
51
|
+
* in readCorrections(), so a malformed-but-valid-JSON record (bad `op`,
|
|
52
|
+
* mismatched `record.id`, missing `record` on an upsert) is otherwise
|
|
53
|
+
* silently trusted. Both consumers (CLI read-merge/self-drain via
|
|
54
|
+
* applyCorrectionToHuman, and Processor.applyCorrectionRecord) call this
|
|
55
|
+
* before mutating anything — it throws, never coerces, so a malformed `op`
|
|
56
|
+
* can never be silently treated as its sibling operation.
|
|
57
|
+
*/
|
|
58
|
+
export function assertValidCorrection(value: unknown): asserts value is CorrectionRecord {
|
|
59
|
+
if (!value || typeof value !== "object") {
|
|
60
|
+
throw new Error(`Malformed correction record: expected an object, got ${JSON.stringify(value)}`);
|
|
61
|
+
}
|
|
62
|
+
if (!("op" in value) || (value.op !== "upsert" && value.op !== "remove")) {
|
|
63
|
+
throw new Error(`Malformed correction record: op must be "upsert" or "remove", got ${JSON.stringify("op" in value ? value.op : undefined)}`);
|
|
64
|
+
}
|
|
65
|
+
if (!("entity_type" in value) || !CORRECTABLE_TYPES.includes(value.entity_type as CorrectableType)) {
|
|
66
|
+
throw new Error(`Malformed correction record: entity_type must be one of ${CORRECTABLE_TYPES.join(", ")}, got ${JSON.stringify("entity_type" in value ? value.entity_type : undefined)}`);
|
|
67
|
+
}
|
|
68
|
+
if (!("id" in value) || typeof value.id !== "string" || value.id.length === 0) {
|
|
69
|
+
throw new Error(`Malformed correction record: id must be a non-empty string, got ${JSON.stringify("id" in value ? value.id : undefined)}`);
|
|
70
|
+
}
|
|
71
|
+
if (value.op === "upsert") {
|
|
72
|
+
if (!("record" in value) || !value.record || typeof value.record !== "object") {
|
|
73
|
+
throw new Error(`Malformed correction record: upsert requires a record object`);
|
|
74
|
+
}
|
|
75
|
+
if (!("id" in value.record) || value.record.id !== value.id) {
|
|
76
|
+
throw new Error(`Malformed correction record: record.id (${JSON.stringify("id" in value.record ? value.record.id : undefined)}) must equal wrapper id (${JSON.stringify(value.id)})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Read pending corrections without a lock. Returns [] if the file doesn't
|
|
83
|
+
* exist or is empty. fs/promises is imported dynamically, not statically —
|
|
84
|
+
* this module is transitively bundled into Web's browser build via
|
|
85
|
+
* src/core/processor.ts, and a static `import { readFile } from "fs/promises"`
|
|
86
|
+
* fails Vite's rollup build (no named exports on the browser-externalized
|
|
87
|
+
* stub), not just at runtime.
|
|
88
|
+
*/
|
|
89
|
+
export async function readCorrections(correctionsPath: string): Promise<CorrectionRecord[]> {
|
|
90
|
+
const { readFile } = await import(/* @vite-ignore */ "fs/promises");
|
|
91
|
+
let text: string;
|
|
92
|
+
try {
|
|
93
|
+
text = await readFile(correctionsPath, "utf-8");
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
if (!text) return [];
|
|
98
|
+
return JSON.parse(text) as CorrectionRecord[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Append one correction under lock (read-modify-write — corrections.json
|
|
103
|
+
* is a JSON array, not an append-only log, so concurrent writers must
|
|
104
|
+
* serialize through the same lock rather than racing on a blind append).
|
|
105
|
+
*/
|
|
106
|
+
export async function appendCorrection(
|
|
107
|
+
correctionsPath: string,
|
|
108
|
+
record: CorrectionRecord
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
await withLock(correctionsPath, async () => {
|
|
111
|
+
const existing = await readCorrections(correctionsPath);
|
|
112
|
+
existing.push(record);
|
|
113
|
+
await atomicWrite(correctionsPath, JSON.stringify(existing, null, 2));
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Merge a person's `name` from its primary identifier, replicating the
|
|
119
|
+
* invariant HumanState.person_upsert enforces on the live-write path
|
|
120
|
+
* (CONTRACTS.md "Person Identifiers — name Sync Rule"). Both consumers of
|
|
121
|
+
* this module (Live's StateManager and the CLI's self-drain) must produce
|
|
122
|
+
* an identical HumanEntity for a given corrections.json, so the formula
|
|
123
|
+
* is centralized here rather than re-derived at each call site.
|
|
124
|
+
*/
|
|
125
|
+
function syncPersonName(person: Person): Person {
|
|
126
|
+
const identifiers = person.identifiers ?? [];
|
|
127
|
+
const primary = identifiers.find((i) => i.is_primary) ?? identifiers[0];
|
|
128
|
+
return primary ? { ...person, name: primary.value } : person;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve the target array for a CorrectableType. Throws on anything
|
|
133
|
+
* other than the 4 known types — corrections.json is external input from
|
|
134
|
+
* CLI/MCP tools (potentially LLM-driven), and a malformed entity_type must
|
|
135
|
+
* never silently fall through to the people array. Live's Processor
|
|
136
|
+
* already enforces this (applyCorrectionRecord); this is the equivalent
|
|
137
|
+
* guard for the CLI read-merge and self-drain paths.
|
|
138
|
+
*/
|
|
139
|
+
function getCorrectableArray(human: HumanEntity, entityType: string): Array<{ id: string }> {
|
|
140
|
+
if (entityType === "fact") return human.facts;
|
|
141
|
+
if (entityType === "topic") return human.topics;
|
|
142
|
+
if (entityType === "person") return human.people;
|
|
143
|
+
if (entityType === "quote") return human.quotes;
|
|
144
|
+
throw new Error(`Unrecognized correction entity_type: ${entityType}`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Apply one correction to a HumanEntity in place, mirroring the exact
|
|
149
|
+
* upsert/remove semantics of HumanState (src/core/state/human.ts) —
|
|
150
|
+
* replace-by-id for upsert, splice + orphaned quote-reference cleanup for
|
|
151
|
+
* remove (for all 3 types, matching fact_remove/topic_remove/person_remove
|
|
152
|
+
* in HumanState — not just person). Used by both the CLI's read-merge
|
|
153
|
+
* (materializing a corrected view without a StateManager) and its
|
|
154
|
+
* self-drain (writing corrections straight into state.json when no live
|
|
155
|
+
* instance is running).
|
|
156
|
+
*/
|
|
157
|
+
export function applyCorrectionToHuman(human: HumanEntity, correction: CorrectionRecord): void {
|
|
158
|
+
assertValidCorrection(correction);
|
|
159
|
+
const array = getCorrectableArray(human, correction.entity_type);
|
|
160
|
+
|
|
161
|
+
if (correction.op === "remove") {
|
|
162
|
+
const idx = array.findIndex((item) => item.id === correction.id);
|
|
163
|
+
if (idx < 0) return;
|
|
164
|
+
array.splice(idx, 1);
|
|
165
|
+
human.quotes.forEach((q) => {
|
|
166
|
+
q.data_item_ids = q.data_item_ids.filter((itemId) => itemId !== correction.id);
|
|
167
|
+
});
|
|
168
|
+
human.last_updated = new Date().toISOString();
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const record = correction.entity_type === "person"
|
|
173
|
+
? syncPersonName(correction.record as Person)
|
|
174
|
+
: correction.record;
|
|
175
|
+
const idx = array.findIndex((item) => item.id === correction.id);
|
|
176
|
+
if (idx >= 0) {
|
|
177
|
+
array[idx] = record;
|
|
178
|
+
} else {
|
|
179
|
+
array.push(record);
|
|
180
|
+
}
|
|
181
|
+
human.last_updated = new Date().toISOString();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Apply every pending correction to a HumanEntity, in file order (later records for the same id win). */
|
|
185
|
+
export function applyCorrectionsToHuman(human: HumanEntity, corrections: CorrectionRecord[]): void {
|
|
186
|
+
for (const correction of corrections) {
|
|
187
|
+
applyCorrectionToHuman(human, correction);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Apply one correction to a StorageState's personas map in place. Personas
|
|
193
|
+
* live outside HumanEntity — src/core/types/integrations.ts's StorageState.personas
|
|
194
|
+
* is a top-level `Record<id, {entity, messages}>` — so they need their own
|
|
195
|
+
* apply function rather than routing through getCorrectableArray/
|
|
196
|
+
* applyCorrectionToHuman, which only ever resolve arrays on HumanEntity.
|
|
197
|
+
*
|
|
198
|
+
* The reserved-persona delete guard here is defense-in-depth for a
|
|
199
|
+
* hand-edited corrections.json: the primary guard is the SYNCHRONOUS check
|
|
200
|
+
* in src/cli/persona-corrections.ts's removePersonaEntity, which runs
|
|
201
|
+
* before a correction is ever queued (so a live-drained rejection here can
|
|
202
|
+
* never surface as a silent no-op after an apparent CLI success).
|
|
203
|
+
*/
|
|
204
|
+
export function applyCorrectionToPersonas(personas: StorageState["personas"], correction: CorrectionRecord): void {
|
|
205
|
+
assertValidCorrection(correction);
|
|
206
|
+
if (correction.op === "remove") {
|
|
207
|
+
if (isReservedPersonaId(correction.id)) {
|
|
208
|
+
throw new Error(`Cannot delete reserved persona "${correction.id}". Use archive instead.`);
|
|
209
|
+
}
|
|
210
|
+
delete personas[correction.id];
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
personas[correction.id] = {
|
|
214
|
+
entity: correction.record as PersonaEntity,
|
|
215
|
+
messages: personas[correction.id]?.messages ?? [],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Route one correction to its target: the personas map for entity_type "persona", the HumanEntity for everything else. */
|
|
220
|
+
export function applyCorrectionToState(state: StorageState, correction: CorrectionRecord): void {
|
|
221
|
+
if (correction.entity_type === "persona") {
|
|
222
|
+
applyCorrectionToPersonas(state.personas, correction);
|
|
223
|
+
} else {
|
|
224
|
+
applyCorrectionToHuman(state.human, correction);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Apply every pending correction to a StorageState, in file order (later records for the same id win). */
|
|
229
|
+
export function applyCorrectionsToState(state: StorageState, corrections: CorrectionRecord[]): void {
|
|
230
|
+
for (const correction of corrections) {
|
|
231
|
+
applyCorrectionToState(state, correction);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -282,7 +282,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
282
282
|
value: i.value,
|
|
283
283
|
...(i.is_primary ? { is_primary: i.is_primary } : {}),
|
|
284
284
|
})),
|
|
285
|
-
state
|
|
285
|
+
state.persona_getAll()
|
|
286
286
|
);
|
|
287
287
|
const allCandidateIds = [...llmIdentifiers, ...candidateIdentifiers];
|
|
288
288
|
if (allCandidateIds.length === 0) {
|
|
@@ -303,7 +303,7 @@ export async function handlePersonUpdate(response: LLMResponse, state: StateMana
|
|
|
303
303
|
...i,
|
|
304
304
|
type: normalizeIdentifierType(i.type, state),
|
|
305
305
|
})),
|
|
306
|
-
state
|
|
306
|
+
state.persona_getAll()
|
|
307
307
|
);
|
|
308
308
|
for (const id of sanitizedToAdd) {
|
|
309
309
|
if (!base.some(e => e.value === id.value)) {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared boolean-map <-> flat-id-array conversion for PersonaEntity.tools.
|
|
3
|
+
*
|
|
4
|
+
* PersonaEntity.tools is persisted (and written by external CRUD callers)
|
|
5
|
+
* as a flat array of ToolDefinition ids. That's opaque to a human or an
|
|
6
|
+
* external agent — nothing about a bare UUID says which provider it
|
|
7
|
+
* belongs to, what it's called, or whether it's even grantable right now
|
|
8
|
+
* (a tool under a disabled provider is unusable regardless of whether its
|
|
9
|
+
* id sits in this array). buildPersonaToolsMap/resolvePersonaToolsFromMap
|
|
10
|
+
* convert between that flat storage shape and a self-documenting
|
|
11
|
+
* `{ [providerDisplayName]: { [toolDisplayName]: boolean } }` map — the
|
|
12
|
+
* same shape the TUI's $EDITOR/YAML persona editor has used since before
|
|
13
|
+
* this module existed (originally defined in
|
|
14
|
+
* tui/src/util/yaml-persona.ts, relocated here so the external CRUD
|
|
15
|
+
* surface — src/cli/retrieval.ts's lookupById and
|
|
16
|
+
* src/cli/persona-corrections.ts's create/update — can present and accept
|
|
17
|
+
* the identical shape instead of a raw UUID array).
|
|
18
|
+
*
|
|
19
|
+
* Relocation is verbatim: same names, same signatures, same bodies as the
|
|
20
|
+
* TUI originals. The TUI now imports both functions from here rather than
|
|
21
|
+
* defining its own copies.
|
|
22
|
+
*/
|
|
23
|
+
import type { ToolDefinition, ToolProvider } from "./types.js";
|
|
24
|
+
|
|
25
|
+
export function buildPersonaToolsMap(
|
|
26
|
+
enabledToolIds: string[],
|
|
27
|
+
allTools: ToolDefinition[],
|
|
28
|
+
allProviders: ToolProvider[]
|
|
29
|
+
): Record<string, Record<string, boolean>> | undefined {
|
|
30
|
+
if (allTools.length === 0) return undefined;
|
|
31
|
+
const enabledSet = new Set(enabledToolIds);
|
|
32
|
+
const result: Record<string, Record<string, boolean>> = {};
|
|
33
|
+
for (const provider of allProviders.filter(p => p.enabled)) {
|
|
34
|
+
const providerTools = allTools.filter(t => t.provider_id === provider.id);
|
|
35
|
+
if (providerTools.length === 0) continue;
|
|
36
|
+
result[provider.display_name] = Object.fromEntries(
|
|
37
|
+
providerTools.map(t => [t.display_name, enabledSet.has(t.id)])
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
return Object.keys(result).length > 0 ? result : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function resolvePersonaToolsFromMap(
|
|
44
|
+
toolsMap: Record<string, Record<string, boolean>> | undefined,
|
|
45
|
+
allTools: ToolDefinition[],
|
|
46
|
+
allProviders: ToolProvider[]
|
|
47
|
+
): string[] | undefined {
|
|
48
|
+
if (!toolsMap) return undefined;
|
|
49
|
+
const enabledIds: string[] = [];
|
|
50
|
+
for (const [providerDisplayName, toolToggles] of Object.entries(toolsMap)) {
|
|
51
|
+
const provider = allProviders.find(p => p.display_name === providerDisplayName);
|
|
52
|
+
if (!provider) continue;
|
|
53
|
+
for (const [toolDisplayName, enabled] of Object.entries(toolToggles)) {
|
|
54
|
+
if (!enabled) continue;
|
|
55
|
+
const tool = allTools.find(t => t.provider_id === provider.id && t.display_name === toolDisplayName);
|
|
56
|
+
if (tool) enabledIds.push(tool.id);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return enabledIds.length > 0 ? enabledIds : [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A tool id belonging to a currently-disabled provider is invisible to
|
|
64
|
+
* buildPersonaToolsMap and therefore can never appear in a caller's
|
|
65
|
+
* submitted map -- not because the caller chose to omit it, but because
|
|
66
|
+
* they had no way to see it. Full-record-replace semantics only apply to
|
|
67
|
+
* the portion of tools[] a caller could actually read and edit; anything
|
|
68
|
+
* outside that boundary must survive an edit unconditionally, or a normal
|
|
69
|
+
* read-edit-write cycle silently destroys stored state the caller never
|
|
70
|
+
* had a chance to preserve correctly. This is intentionally NOT a general
|
|
71
|
+
* merge -- resolvedVisibleIds still fully governs every id whose provider
|
|
72
|
+
* IS enabled; only ids that are structurally unaddressable right now get
|
|
73
|
+
* carried forward.
|
|
74
|
+
*/
|
|
75
|
+
export function preserveHiddenToolGrants(
|
|
76
|
+
resolvedVisibleIds: string[] | undefined,
|
|
77
|
+
existingIds: string[] | undefined,
|
|
78
|
+
allTools: ToolDefinition[],
|
|
79
|
+
allProviders: ToolProvider[]
|
|
80
|
+
): string[] | undefined {
|
|
81
|
+
const providerById = new Map(allProviders.map(p => [p.id, p]));
|
|
82
|
+
const toolById = new Map(allTools.map(t => [t.id, t]));
|
|
83
|
+
const hidden = (existingIds ?? []).filter(id => {
|
|
84
|
+
const tool = toolById.get(id);
|
|
85
|
+
if (!tool) return false;
|
|
86
|
+
const provider = providerById.get(tool.provider_id);
|
|
87
|
+
return provider ? !provider.enabled : false;
|
|
88
|
+
});
|
|
89
|
+
if (hidden.length === 0) return resolvedVisibleIds;
|
|
90
|
+
const merged = new Set([...(resolvedVisibleIds ?? []), ...hidden]);
|
|
91
|
+
return merged.size > 0 ? Array.from(merged) : undefined;
|
|
92
|
+
}
|
package/src/core/processor.ts
CHANGED
|
@@ -23,10 +23,14 @@ import {
|
|
|
23
23
|
type ToolDefinition,
|
|
24
24
|
type ToolProvider,
|
|
25
25
|
} from "./types.js";
|
|
26
|
+
import { isReservedPersonaId } from "./types.js";
|
|
26
27
|
import { buildPersonaFromPersonPrompt } from "../prompts/index.js";
|
|
27
28
|
import { buildSiblingAwarenessSection } from "../prompts/room/index.js";
|
|
28
29
|
import type { PersonaGenerationResult } from "../prompts/generation/types.js";
|
|
29
30
|
|
|
31
|
+
import { readCorrections, assertValidCorrection } from "./corrections.js";
|
|
32
|
+
import type { CorrectionRecord } from "./corrections.js";
|
|
33
|
+
import { withLock, atomicWrite } from "../storage/file-lock.js";
|
|
30
34
|
import type { Storage } from "../storage/interface.js";
|
|
31
35
|
import { remoteSync } from "../storage/remote.js";
|
|
32
36
|
import { yoloMerge } from "../storage/merge.js";
|
|
@@ -726,7 +730,115 @@ const toolNextSteps = new Set([
|
|
|
726
730
|
console.log(`[Processor ${this.instanceId}] runLoop() exited`);
|
|
727
731
|
}
|
|
728
732
|
|
|
733
|
+
/**
|
|
734
|
+
* Drain corrections.json into the live StateManager, every tick (no
|
|
735
|
+
* time-gating — corrections need near-instant pickup, that's the whole
|
|
736
|
+
* point of the external write path). Read + apply + delete all happen
|
|
737
|
+
* inside one withLock() call so a concurrent CLI appendCorrection() can't
|
|
738
|
+
* race the read-drain-delete sequence and lose a correction appended
|
|
739
|
+
* mid-drain.
|
|
740
|
+
*
|
|
741
|
+
* A malformed record (bad entity_type, or a StateManager call that
|
|
742
|
+
* throws) is dropped with a loud console.error rather than aborting the
|
|
743
|
+
* whole batch — corrections.json is external input from CLI/MCP tools
|
|
744
|
+
* potentially driven by an LLM agent, so one bad record must not wedge
|
|
745
|
+
* every subsequent correction behind it forever. This mirrors the
|
|
746
|
+
* existing DLQ/heartbeat error-tolerance philosophy in this file: log
|
|
747
|
+
* loudly, keep the loop alive, keep draining valid records.
|
|
748
|
+
*/
|
|
749
|
+
private async drainCorrections(): Promise<void> {
|
|
750
|
+
if (!this.storage) return;
|
|
751
|
+
try {
|
|
752
|
+
const dataPath = this.storage.getDataPath();
|
|
753
|
+
// Web's IndexedDBStorage/LocalStorage return "" — no filesystem, no
|
|
754
|
+
// corrections.json to drain. This mechanism is filesystem-only
|
|
755
|
+
// (TUI/future daemon); Web sessions never write corrections.json in
|
|
756
|
+
// the first place since the CLI/MCP tools that produce it are Node/Bun-only.
|
|
757
|
+
if (!dataPath) return;
|
|
758
|
+
const { join } = await import(/* @vite-ignore */ "path");
|
|
759
|
+
const correctionsPath = join(dataPath, "corrections.json");
|
|
760
|
+
|
|
761
|
+
// Cheap fast path: this runs every 100ms, and readCorrections()
|
|
762
|
+
// short-circuits on a missing file without touching the lock.
|
|
763
|
+
const pending = await readCorrections(correctionsPath);
|
|
764
|
+
if (pending.length === 0) return;
|
|
765
|
+
|
|
766
|
+
await withLock(correctionsPath, async () => {
|
|
767
|
+
// Re-read inside the lock in case a correction was appended between
|
|
768
|
+
// the unlocked fast-path read above and lock acquisition.
|
|
769
|
+
const records = await readCorrections(correctionsPath);
|
|
770
|
+
if (records.length === 0) return;
|
|
771
|
+
|
|
772
|
+
for (const record of records) {
|
|
773
|
+
try {
|
|
774
|
+
this.applyCorrectionRecord(record);
|
|
775
|
+
} catch (err) {
|
|
776
|
+
console.error(`[Processor ${this.instanceId}] Dropping malformed correction record:`, JSON.stringify(record), err);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
await atomicWrite(correctionsPath, "[]");
|
|
781
|
+
this.interface.onHumanUpdated?.();
|
|
782
|
+
});
|
|
783
|
+
} catch (err) {
|
|
784
|
+
console.error(`[Processor ${this.instanceId}] Failed to drain corrections.json:`, err);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** Dispatch one CorrectionRecord to the matching StateManager upsert/remove call. Throws on an unrecognized entity_type or op (see assertValidCorrection — the same validator the CLI read-merge/self-drain path uses, so a malformed op can never be silently treated as its sibling operation here). */
|
|
789
|
+
private applyCorrectionRecord(record: CorrectionRecord): void {
|
|
790
|
+
assertValidCorrection(record);
|
|
791
|
+
if (record.entity_type === "fact") {
|
|
792
|
+
if (record.op === "upsert") {
|
|
793
|
+
this.stateManager.human_fact_upsert(record.record as Fact);
|
|
794
|
+
} else {
|
|
795
|
+
this.stateManager.human_fact_remove(record.id);
|
|
796
|
+
}
|
|
797
|
+
} else if (record.entity_type === "topic") {
|
|
798
|
+
if (record.op === "upsert") {
|
|
799
|
+
this.stateManager.human_topic_upsert(record.record as Topic);
|
|
800
|
+
} else {
|
|
801
|
+
this.stateManager.human_topic_remove(record.id);
|
|
802
|
+
}
|
|
803
|
+
} else if (record.entity_type === "person") {
|
|
804
|
+
if (record.op === "upsert") {
|
|
805
|
+
this.stateManager.human_person_upsert(record.record as Person);
|
|
806
|
+
} else {
|
|
807
|
+
this.stateManager.human_person_remove(record.id);
|
|
808
|
+
}
|
|
809
|
+
} else if (record.entity_type === "quote") {
|
|
810
|
+
if (record.op === "upsert") {
|
|
811
|
+
this.stateManager.human_quote_upsert(record.record as Quote);
|
|
812
|
+
} else {
|
|
813
|
+
this.stateManager.human_quote_remove(record.id);
|
|
814
|
+
}
|
|
815
|
+
} else if (record.entity_type === "persona") {
|
|
816
|
+
if (record.op === "upsert") {
|
|
817
|
+
const existing = this.stateManager.persona_getById(record.id);
|
|
818
|
+
if (existing) {
|
|
819
|
+
this.stateManager.persona_replace(record.id, record.record as PersonaEntity);
|
|
820
|
+
} else {
|
|
821
|
+
this.stateManager.persona_add(record.record as PersonaEntity);
|
|
822
|
+
}
|
|
823
|
+
} else {
|
|
824
|
+
// Defense in depth — this should never reach here since the
|
|
825
|
+
// synchronous write-time guard in src/cli/persona-corrections.ts's
|
|
826
|
+
// removePersonaEntity already rejects a reserved id before ever
|
|
827
|
+
// queuing the correction. corrections.json is external input,
|
|
828
|
+
// though, and could theoretically be hand-edited to bypass that.
|
|
829
|
+
if (isReservedPersonaId(record.id)) {
|
|
830
|
+
throw new Error(`Cannot delete reserved persona "${record.id}". Use archive instead.`);
|
|
831
|
+
}
|
|
832
|
+
this.stateManager.persona_delete(record.id);
|
|
833
|
+
}
|
|
834
|
+
} else {
|
|
835
|
+
throw new Error(`Unrecognized correction entity_type: ${JSON.stringify(record)}`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
729
839
|
private async checkScheduledTasks(): Promise<void> {
|
|
840
|
+
await this.drainCorrections();
|
|
841
|
+
|
|
730
842
|
const now = Date.now();
|
|
731
843
|
|
|
732
844
|
const human = this.stateManager.getHuman();
|
|
@@ -1291,7 +1403,7 @@ const toolNextSteps = new Set([
|
|
|
1291
1403
|
}
|
|
1292
1404
|
|
|
1293
1405
|
async upsertPerson(person: Person): Promise<void> {
|
|
1294
|
-
const sanitized = { ...person, identifiers: sanitizeEiPersonaIdentifiers(person.identifiers ?? [], this.stateManager) };
|
|
1406
|
+
const sanitized = { ...person, identifiers: sanitizeEiPersonaIdentifiers(person.identifiers ?? [], this.stateManager.persona_getAll()) };
|
|
1295
1407
|
await upsertPerson(this.stateManager, sanitized);
|
|
1296
1408
|
this.interface.onHumanUpdated?.();
|
|
1297
1409
|
}
|
package/src/core/state/human.ts
CHANGED
|
@@ -118,6 +118,16 @@ export class HumanState {
|
|
|
118
118
|
return false;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
quote_upsert(quote: Quote): void {
|
|
122
|
+
const idx = this.human.quotes.findIndex((q) => q.id === quote.id);
|
|
123
|
+
if (idx >= 0) {
|
|
124
|
+
this.human.quotes[idx] = quote;
|
|
125
|
+
} else {
|
|
126
|
+
this.human.quotes.push(quote);
|
|
127
|
+
}
|
|
128
|
+
this.human.last_updated = new Date().toISOString();
|
|
129
|
+
}
|
|
130
|
+
|
|
121
131
|
quote_add(quote: Quote): void {
|
|
122
132
|
if (!quote.created_at) {
|
|
123
133
|
quote.created_at = new Date().toISOString();
|