ei-tui 1.6.9 → 1.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +2 -1
- package/skills/coding-harness-reflect/SKILL.md +230 -0
- package/skills/ei-curate/SKILL.md +174 -0
- package/skills/ei-curate/references/cli.md +160 -0
- package/skills/ei-curate/references/provenance.md +88 -0
- package/skills/ei-curate/references/recipes.md +144 -0
- package/skills/ei-curate/references/talking-to-the-user.md +71 -0
- package/skills/ei-persona/SKILL.md +238 -0
- package/skills/ei-persona/references/cli.md +265 -0
- package/skills/ei-persona/references/recipes.md +247 -0
- package/skills/ei-persona/references/talking-to-the-user.md +107 -0
- package/src/cli/README.md +72 -2
- package/src/cli/corrections-endpoints.ts +297 -0
- package/src/cli/corrections-writer.ts +138 -0
- package/src/cli/install.ts +117 -25
- package/src/cli/mcp.ts +80 -1
- package/src/cli/persona-corrections.ts +442 -0
- package/src/cli/retrieval.ts +46 -2
- package/src/cli.ts +131 -1
- package/src/core/corrections.ts +233 -0
- package/src/core/handlers/human-extraction.ts +64 -15
- package/src/core/handlers/human-matching.ts +2 -2
- package/src/core/orchestrators/human-extraction.ts +1 -11
- package/src/core/persona-manager.ts +18 -7
- package/src/core/persona-tools.ts +92 -0
- package/src/core/processor.ts +113 -1
- package/src/core/state/human.ts +10 -0
- package/src/core/state/personas.ts +8 -0
- package/src/core/state-manager.ts +11 -0
- package/src/core/utils/identifier-utils.ts +3 -2
- package/src/prompts/human/person-scan.ts +2 -0
- package/src/prompts/human/person-update.ts +14 -3
- package/src/storage/file-lock.ts +120 -0
- package/tui/README.md +2 -0
- package/tui/src/commands/provider.tsx +1 -1
- package/tui/src/components/WelcomeOverlay.tsx +3 -3
- package/tui/src/context/ei.tsx +14 -0
- package/tui/src/index.tsx +13 -1
- package/tui/src/storage/file.ts +15 -83
- package/tui/src/util/instance-lock.ts +3 -2
- package/tui/src/util/yaml-persona.ts +7 -38
|
@@ -0,0 +1,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();
|
|
@@ -80,6 +80,14 @@ export class PersonaState {
|
|
|
80
80
|
return true;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** Full replace of a persona's entity (not a merge) — used by the live-drain path for corrections-queue upserts, where record.record is already a complete PersonaEntity and any field genuinely absent from it must NOT survive from the prior value. Preserves .messages untouched, same as update(). */
|
|
84
|
+
replace(personaId: string, entity: PersonaEntity): boolean {
|
|
85
|
+
const data = this.personas.get(personaId);
|
|
86
|
+
if (!data) return false;
|
|
87
|
+
data.entity = { ...entity, last_updated: new Date().toISOString() };
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
83
91
|
archive(personaId: string): boolean {
|
|
84
92
|
const data = this.personas.get(personaId);
|
|
85
93
|
if (!data) return false;
|
|
@@ -715,6 +715,11 @@ export class StateManager {
|
|
|
715
715
|
this.scheduleSave();
|
|
716
716
|
}
|
|
717
717
|
|
|
718
|
+
human_quote_upsert(quote: Quote): void {
|
|
719
|
+
this.humanState.quote_upsert(quote);
|
|
720
|
+
this.scheduleSave();
|
|
721
|
+
}
|
|
722
|
+
|
|
718
723
|
human_quote_update(id: string, updates: Partial<Quote>): boolean {
|
|
719
724
|
const result = this.humanState.quote_update(id, updates);
|
|
720
725
|
this.scheduleSave();
|
|
@@ -758,6 +763,12 @@ export class StateManager {
|
|
|
758
763
|
return result;
|
|
759
764
|
}
|
|
760
765
|
|
|
766
|
+
persona_replace(personaId: string, entity: PersonaEntity): boolean {
|
|
767
|
+
const result = this.personaState.replace(personaId, entity);
|
|
768
|
+
this.scheduleSave();
|
|
769
|
+
return result;
|
|
770
|
+
}
|
|
771
|
+
|
|
761
772
|
persona_archive(personaId: string): boolean {
|
|
762
773
|
const result = this.personaState.archive(personaId);
|
|
763
774
|
this.scheduleSave();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { PersonIdentifier } from "../types/data-items.js";
|
|
2
|
+
import type { PersonaEntity } from "../types/entities.js";
|
|
2
3
|
import type { StateManager } from "../state-manager.js";
|
|
3
4
|
import { BUILT_IN_IDENTIFIER_TYPES } from "../constants/built-in-identifier-types.js";
|
|
4
5
|
|
|
@@ -29,12 +30,12 @@ export function normalizeIdentifierType(llmType: string, state: StateManager): s
|
|
|
29
30
|
|
|
30
31
|
export function sanitizeEiPersonaIdentifiers(
|
|
31
32
|
identifiers: PersonIdentifier[],
|
|
32
|
-
|
|
33
|
+
personas: PersonaEntity[]
|
|
33
34
|
): PersonIdentifier[] {
|
|
34
35
|
return identifiers.map(id => {
|
|
35
36
|
if (id.type !== 'Ei Persona' && id.type !== 'AI Persona') return id;
|
|
36
37
|
if (UUID_REGEX.test(id.value)) return { ...id, type: 'Ei Persona' };
|
|
37
|
-
const matched =
|
|
38
|
+
const matched = personas.find(p =>
|
|
38
39
|
p.display_name === id.value || p.aliases?.includes(id.value)
|
|
39
40
|
);
|
|
40
41
|
if (matched) return { ...id, type: 'Ei Persona', value: matched.id };
|
|
@@ -107,6 +107,8 @@ If you are unsure of the type, use \`Nickname\` as a fallback. Do NOT invent typ
|
|
|
107
107
|
|
|
108
108
|
Only include \`identifiers\` when explicitly mentioned in the conversation — omit it entirely if nothing qualifies.
|
|
109
109
|
|
|
110
|
+
Do NOT attribute one person's handle or identifier to another person discussed in the same window. If "Marcus's GitHub is @mcodes" appears alongside a mention of Priya, @mcodes belongs to Marcus's record only — never Priya's.
|
|
111
|
+
|
|
110
112
|
## Confidence & Relationship Type
|
|
111
113
|
|
|
112
114
|
For each person, rate how important they are to the human user's life:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { PromptOutput, ParticipantContext } from "./types.js";
|
|
2
2
|
import type { Person, Message } from "../../core/types.js";
|
|
3
|
+
import type { PersonIdentifier } from "../../core/types/data-items.js";
|
|
3
4
|
import { formatMessagesAsPlaceholders } from "../message-utils.js";
|
|
4
5
|
|
|
5
6
|
export interface PersonUpdatePromptData {
|
|
@@ -12,6 +13,7 @@ export interface PersonUpdatePromptData {
|
|
|
12
13
|
persona_name: string;
|
|
13
14
|
participant_context?: ParticipantContext;
|
|
14
15
|
known_identifier_types?: string[];
|
|
16
|
+
suggested_identifiers?: PersonIdentifier[];
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
function participantContextSection(ctx: ParticipantContext | undefined): string {
|
|
@@ -171,20 +173,29 @@ The description should NOT:
|
|
|
171
173
|
? `CRITICAL: The HUMAN USER is ${humanName}. They wrote these messages. Do NOT assign their names, nicknames, or handles as identifiers for this person's record — UNLESS this IS the user's own Self record (relationship: "Self").`
|
|
172
174
|
: `CRITICAL: The HUMAN USER wrote these messages. Do NOT assign their own names or handles as identifiers for this person's record — UNLESS this IS the user's own Self record (relationship: "Self"). Do NOT return \`relationship: "Self"\` unless you are certain this record is about the human user themselves.`;
|
|
173
175
|
|
|
176
|
+
const attributionGuard = `\nONLY add an identifier if it is explicitly stated about THIS SPECIFIC PERSON — not inferred from proximity in the conversation. If two different people are discussed near each other, do NOT attribute one person's handle, email, or name to the other.`;
|
|
174
177
|
const isUnknownNewPerson = isNewItem && personName === 'Unknown';
|
|
175
178
|
const unknownIdentifierGuard = isUnknownNewPerson
|
|
176
|
-
? `\nThis person's name is not yet known
|
|
179
|
+
? `\nThis person's name is not yet known — be especially careful: only record an identifier the conversation names for THEM specifically.`
|
|
177
180
|
: '';
|
|
178
181
|
|
|
179
|
-
const
|
|
182
|
+
const suggestedIdentifiers = data.suggested_identifiers ?? [];
|
|
183
|
+
const suggestedIdentifiersBlock = !isNewItem && suggestedIdentifiers.length > 0
|
|
184
|
+
? `\n\nThe scan flagged these identifiers as POSSIBLY belonging to this person: ${suggestedIdentifiers.map(i => `\`${i.type}=${i.value}\``).join(', ')}.
|
|
185
|
+
For EACH, add it to \`identifiers_to_add\` ONLY if the Most Recent Messages confirm it belongs to THIS SPECIFIC PERSON. If any actually belongs to someone else mentioned in the conversation, omit it. Do not add any you cannot confirm from the conversation.`
|
|
186
|
+
: '';
|
|
187
|
+
|
|
188
|
+
const identifierSection = `${identityGuard}${attributionGuard}${unknownIdentifierGuard}
|
|
180
189
|
|
|
181
190
|
If you spot a platform handle, username, email, nickname, or full name explicitly mentioned in the conversation that isn't already in the person's identifiers, include it in \`identifiers_to_add\` (updates) or \`identifiers\` (new records). Always mark exactly one identifier as \`"is_primary": true\` — prefer the most formal or complete name.
|
|
182
191
|
|
|
192
|
+
Example of what NOT to do: if the conversation says "I talked to Priya and Marcus — Marcus's GitHub is @mcodes", do NOT add @mcodes to Priya's record; it belongs to Marcus only.
|
|
193
|
+
|
|
183
194
|
For persons with a known relationship (Father, Mother, Sibling, etc.), also look for informal terms the HUMAN USER uses to address or refer to THAT SPECIFIC PERSON (\`Dad\`, \`Pop\`, \`Mom\`, \`Sis\`, etc.) and add them as \`{ "type": "Relationship", "value": "..." }\` identifiers.
|
|
184
195
|
|
|
185
196
|
NEVER add dates, ages, birthdays, or anniversaries as identifiers. These are not identifying labels — if known, include them in the description instead.
|
|
186
197
|
|
|
187
|
-
Known identifier types: ${allTypes}. If unsure of type, use \`Nickname
|
|
198
|
+
Known identifier types: ${allTypes}. If unsure of type, use \`Nickname\`.${suggestedIdentifiersBlock}`;
|
|
188
199
|
|
|
189
200
|
// ── OUTPUT FORMAT ─────────────────────────────────────────────────────────
|
|
190
201
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-based advisory locking + atomic writes, shared by any Storage
|
|
3
|
+
* implementation (and CLI tooling) that needs to serialize writes to a
|
|
4
|
+
* JSON file on disk without a database.
|
|
5
|
+
*
|
|
6
|
+
* Extracted from tui/src/storage/file.ts so the CLI's corrections.json
|
|
7
|
+
* writer/drainer can use identical lock semantics instead of forking a
|
|
8
|
+
* second implementation.
|
|
9
|
+
*
|
|
10
|
+
* fs/promises is imported dynamically per-function, not statically at
|
|
11
|
+
* module scope: this module is transitively imported by src/core/corrections.ts
|
|
12
|
+
* -> src/core/processor.ts, which Web's Vite build bundles for the browser.
|
|
13
|
+
* A static `import { readFile } from "fs/promises"` makes rollup try to
|
|
14
|
+
* resolve named exports against Vite's browser-externalized stub, which
|
|
15
|
+
* has none — that's a hard build failure, not a runtime one (same pattern
|
|
16
|
+
* already used in src/cli.ts and src/cli/install.ts for this exact reason).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const LOCK_TIMEOUT_MS = 5000;
|
|
20
|
+
const LOCK_RETRY_DELAY_MS = 50;
|
|
21
|
+
|
|
22
|
+
// Plain executor form, not Promise.withResolvers — this module runs on
|
|
23
|
+
// whatever Node the CLI/TUI ships with, and withResolvers only landed in
|
|
24
|
+
// Node 22. No behavior difference, just broader runtime compatibility.
|
|
25
|
+
function delay(ms: number): Promise<void> {
|
|
26
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getLockPath(filePath: string): string {
|
|
30
|
+
return `${filePath}.lock`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Acquire an advisory lock on filePath. Stale locks (older than
|
|
35
|
+
* LOCK_TIMEOUT_MS) are broken automatically. Returns false if the lock
|
|
36
|
+
* could not be acquired within LOCK_TIMEOUT_MS.
|
|
37
|
+
*/
|
|
38
|
+
export async function acquireLock(filePath: string): Promise<boolean> {
|
|
39
|
+
const { readFile, writeFile, unlink } = await import(/* @vite-ignore */ "fs/promises");
|
|
40
|
+
const lockPath = getLockPath(filePath);
|
|
41
|
+
const startTime = Date.now();
|
|
42
|
+
|
|
43
|
+
while (Date.now() - startTime < LOCK_TIMEOUT_MS) {
|
|
44
|
+
// readFile throws ENOENT when the lock is absent — treated the same as
|
|
45
|
+
// "lock vanished mid-read" below: proceed straight to acquire.
|
|
46
|
+
let lockContent: string | null = null;
|
|
47
|
+
try {
|
|
48
|
+
lockContent = await readFile(lockPath, "utf-8");
|
|
49
|
+
} catch {
|
|
50
|
+
lockContent = null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (lockContent !== null) {
|
|
54
|
+
const lockTime = parseInt(lockContent, 10);
|
|
55
|
+
if (!isNaN(lockTime) && Date.now() - lockTime > LOCK_TIMEOUT_MS) {
|
|
56
|
+
try {
|
|
57
|
+
await unlink(lockPath);
|
|
58
|
+
} catch {}
|
|
59
|
+
} else {
|
|
60
|
+
await delay(LOCK_RETRY_DELAY_MS);
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// "wx" = exclusive create, fails with EEXIST if the path already
|
|
67
|
+
// exists. Without this flag, two callers that both observed the lock
|
|
68
|
+
// as absent (the check above) could both reach this writeFile and
|
|
69
|
+
// both succeed — writeFile has no atomicity of its own, it just
|
|
70
|
+
// overwrites. "wx" makes the write itself the atomic test-and-set:
|
|
71
|
+
// only one caller can win it, the other gets EEXIST and falls into
|
|
72
|
+
// the catch below to retry from the top of the loop.
|
|
73
|
+
await writeFile(lockPath, Date.now().toString(), { flag: "wx" });
|
|
74
|
+
return true;
|
|
75
|
+
} catch {
|
|
76
|
+
await delay(LOCK_RETRY_DELAY_MS);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function releaseLock(filePath: string): Promise<void> {
|
|
84
|
+
const { unlink } = await import(/* @vite-ignore */ "fs/promises");
|
|
85
|
+
const lockPath = getLockPath(filePath);
|
|
86
|
+
try {
|
|
87
|
+
await unlink(lockPath);
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Run fn() while holding filePath's advisory lock. Throws
|
|
93
|
+
* STORAGE_LOCK_TIMEOUT if the lock can't be acquired in time.
|
|
94
|
+
*/
|
|
95
|
+
export async function withLock<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
|
96
|
+
const acquired = await acquireLock(filePath);
|
|
97
|
+
if (!acquired) {
|
|
98
|
+
throw new Error("STORAGE_LOCK_TIMEOUT: Could not acquire file lock");
|
|
99
|
+
}
|
|
100
|
+
try {
|
|
101
|
+
return await fn();
|
|
102
|
+
} finally {
|
|
103
|
+
await releaseLock(filePath);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Write content to filePath via temp-file + rename, so readers never see a partial write. */
|
|
108
|
+
export async function atomicWrite(filePath: string, content: string): Promise<void> {
|
|
109
|
+
const { writeFile, rename, unlink } = await import(/* @vite-ignore */ "fs/promises");
|
|
110
|
+
const tempPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2)}`;
|
|
111
|
+
try {
|
|
112
|
+
await writeFile(tempPath, content);
|
|
113
|
+
await rename(tempPath, filePath);
|
|
114
|
+
} catch (e) {
|
|
115
|
+
try {
|
|
116
|
+
await unlink(tempPath);
|
|
117
|
+
} catch {}
|
|
118
|
+
throw e;
|
|
119
|
+
}
|
|
120
|
+
}
|
package/tui/README.md
CHANGED
|
@@ -174,6 +174,8 @@ Rooms have three modes, set at creation time:
|
|
|
174
174
|
| `/tools` | | Manage tool providers — enable/disable tools per persona |
|
|
175
175
|
| `/auth <service>` | | Authenticate with an external service via OAuth. Supported: `spotify`, `slack` |
|
|
176
176
|
|
|
177
|
+
> Disabling a tool provider hides its tools from `/details`' YAML editor, but doesn't revoke them — a persona's grants for a disabled provider's tools survive an unrelated `/details` edit and reappear once you re-enable the provider with `/tools` or `/provider`.
|
|
178
|
+
|
|
177
179
|
### Editor
|
|
178
180
|
|
|
179
181
|
| Command | Aliases | Description |
|
|
@@ -48,7 +48,7 @@ export async function openModelOverlay(ctx: Parameters<Command["execute"]>[1]):
|
|
|
48
48
|
const models = await buildModelList(ctx);
|
|
49
49
|
|
|
50
50
|
if (models.length === 0) {
|
|
51
|
-
ctx
|
|
51
|
+
await createProviderViaEditor(ctx);
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -90,9 +90,9 @@ export function WelcomeOverlay(props: WelcomeOverlayProps) {
|
|
|
90
90
|
<box visible={!hasAny()} flexDirection="column">
|
|
91
91
|
<text fg="#dc322f">No LLM provider detected.</text>
|
|
92
92
|
<text> </text>
|
|
93
|
-
<text fg="#93a1a1">
|
|
94
|
-
<text fg="#93a1a1">
|
|
95
|
-
<text fg="#93a1a1">
|
|
93
|
+
<text fg="#93a1a1">Use /provider new to configure one manually, or</text>
|
|
94
|
+
<text fg="#93a1a1">start LMStudio (port 1234) / Ollama (port 11434), or</text>
|
|
95
|
+
<text fg="#93a1a1">set ANTHROPIC_API_KEY, OPENAI_API_KEY, etc. and restart.</text>
|
|
96
96
|
</box>
|
|
97
97
|
|
|
98
98
|
<text> </text>
|
package/tui/src/context/ei.tsx
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
useContext,
|
|
4
4
|
onMount,
|
|
5
5
|
onCleanup,
|
|
6
|
+
For,
|
|
6
7
|
Match,
|
|
7
8
|
Switch,
|
|
8
9
|
createSignal,
|
|
@@ -183,6 +184,7 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
183
184
|
const [showWelcomeOverlay, setShowWelcomeOverlay] = createSignal(false);
|
|
184
185
|
const [detectedProviders, setDetectedProviders] = createSignal<ProviderDetectionStatus[]>([]);
|
|
185
186
|
const [firstBootDefaultModel, setFirstBootDefaultModel] = createSignal<string | undefined>(undefined);
|
|
187
|
+
const [bootError, setBootError] = createSignal<string | null>(null);
|
|
186
188
|
const [conflictData, setConflictData] = createSignal<StateConflictData | null>(null);
|
|
187
189
|
|
|
188
190
|
let processor: Processor | null = null;
|
|
@@ -970,6 +972,7 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
970
972
|
await finishBootstrap();
|
|
971
973
|
} catch (err: any) {
|
|
972
974
|
logger.error(`bootstrap() failed: ${err?.message || err}`);
|
|
975
|
+
setBootError(err?.message || String(err));
|
|
973
976
|
}
|
|
974
977
|
}
|
|
975
978
|
|
|
@@ -1089,6 +1092,17 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
1089
1092
|
<Match when={store.ready}>
|
|
1090
1093
|
<EiContext.Provider value={value}>{props.children}</EiContext.Provider>
|
|
1091
1094
|
</Match>
|
|
1095
|
+
<Match when={bootError()}>
|
|
1096
|
+
<box width="100%" height="100%" justifyContent="center" alignItems="center" flexDirection="column">
|
|
1097
|
+
<text fg="#dc322f">Ei failed to start</text>
|
|
1098
|
+
<text> </text>
|
|
1099
|
+
<For each={bootError()!.split('\n')}>
|
|
1100
|
+
{(line) => <text fg="#93a1a1">{line || " "}</text>}
|
|
1101
|
+
</For>
|
|
1102
|
+
<text> </text>
|
|
1103
|
+
<text fg="#586e75">Press Ctrl+C to exit</text>
|
|
1104
|
+
</box>
|
|
1105
|
+
</Match>
|
|
1092
1106
|
<Match when={!store.ready}>
|
|
1093
1107
|
<box width="100%" height="100%" justifyContent="center" alignItems="center">
|
|
1094
1108
|
<text>Loading Ei...</text>
|
package/tui/src/index.tsx
CHANGED
|
@@ -14,7 +14,19 @@ if (args.includes("--version") || args.includes("version") || args.includes("-v"
|
|
|
14
14
|
|
|
15
15
|
const storage = new FileStorage(Bun.env.EI_DATA_PATH);
|
|
16
16
|
const lock = new InstanceLock(storage.getDataPath());
|
|
17
|
-
const lockResult = await lock.acquire()
|
|
17
|
+
const lockResult = await lock.acquire().catch((e) => {
|
|
18
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
19
|
+
const dataPath = storage.getDataPath();
|
|
20
|
+
process.stderr.write(
|
|
21
|
+
`\nEi cannot start: cannot write to data directory.\n\n` +
|
|
22
|
+
` Path: ${dataPath}\n` +
|
|
23
|
+
` Error: ${msg}\n\n` +
|
|
24
|
+
`Fix options:\n` +
|
|
25
|
+
` - Fix Permissions (sudo chown $USER $EI_DATA_PATH)\n` +
|
|
26
|
+
` - Change Data Path (EI_DATA_PATH=~/ei-data ei)\n\n`
|
|
27
|
+
);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
});
|
|
18
30
|
|
|
19
31
|
if (!lockResult.acquired) {
|
|
20
32
|
process.stderr.write(
|