ei-tui 0.1.10 → 0.1.13
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/package.json +1 -1
- package/src/cli.ts +58 -0
- package/src/core/handlers/index.ts +76 -21
- package/src/core/llm-client.ts +13 -2
- package/src/core/processor.ts +116 -4
- package/src/core/queue-processor.ts +4 -3
- package/src/core/types.ts +11 -1
- package/src/integrations/claude-code/importer.ts +323 -0
- package/src/integrations/claude-code/index.ts +10 -0
- package/src/integrations/claude-code/reader.ts +238 -0
- package/src/integrations/claude-code/types.ts +163 -0
- package/src/integrations/opencode/importer.ts +10 -3
- package/src/prompts/generation/persona.ts +5 -3
- package/src/prompts/generation/types.ts +1 -1
- package/src/prompts/human/fact-scan.ts +6 -6
- package/src/prompts/human/person-scan.ts +6 -6
- package/src/prompts/human/topic-scan.ts +4 -4
- package/src/prompts/human/trait-scan.ts +6 -6
- package/src/prompts/persona/traits.ts +2 -2
- package/src/prompts/response/sections.ts +15 -0
- package/src/storage/interface.ts +2 -0
- package/src/storage/local.ts +5 -0
- package/tui/README.md +13 -0
- package/tui/src/index.tsx +20 -0
- package/tui/src/storage/file.ts +39 -2
- package/tui/src/util/instance-lock.ts +92 -0
- package/tui/src/util/logger.ts +0 -2
- package/tui/src/util/yaml-serializers.ts +49 -3
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code Integration Types
|
|
3
|
+
*
|
|
4
|
+
* These types represent the data structures read from Claude Code's storage.
|
|
5
|
+
* Sessions are stored as JSONL files in ~/.claude/projects/<encoded-path>/<uuid>.jsonl
|
|
6
|
+
*
|
|
7
|
+
* The encoded path replaces '/' with '-', so /home/user/myapp → -home-user-myapp.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// ============================================================================
|
|
11
|
+
// Reader Interface
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
export interface IClaudeCodeReader {
|
|
15
|
+
getSessions(): Promise<ClaudeCodeSession[]>;
|
|
16
|
+
getMessagesForSession(sessionId: string): Promise<ClaudeCodeMessage[]>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Raw JSONL Record Types
|
|
21
|
+
// ============================================================================
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Common envelope fields present on most records.
|
|
25
|
+
*/
|
|
26
|
+
interface ClaudeCodeRecordBase {
|
|
27
|
+
type: string;
|
|
28
|
+
uuid?: string;
|
|
29
|
+
parentUuid?: string | null;
|
|
30
|
+
sessionId?: string;
|
|
31
|
+
cwd?: string;
|
|
32
|
+
timestamp?: string;
|
|
33
|
+
isSidechain?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* user record — a message the human typed.
|
|
38
|
+
*/
|
|
39
|
+
export interface ClaudeCodeUserRecord extends ClaudeCodeRecordBase {
|
|
40
|
+
type: "user";
|
|
41
|
+
message: {
|
|
42
|
+
role: "user";
|
|
43
|
+
content: string;
|
|
44
|
+
};
|
|
45
|
+
uuid: string;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
cwd: string;
|
|
48
|
+
timestamp: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* assistant record — Claude's response.
|
|
53
|
+
* content is an array of blocks; we extract "text" blocks only.
|
|
54
|
+
*/
|
|
55
|
+
export interface ClaudeCodeAssistantRecord extends ClaudeCodeRecordBase {
|
|
56
|
+
type: "assistant";
|
|
57
|
+
message: {
|
|
58
|
+
model: string;
|
|
59
|
+
role: "assistant";
|
|
60
|
+
content: ClaudeCodeContentBlock[];
|
|
61
|
+
};
|
|
62
|
+
uuid: string;
|
|
63
|
+
sessionId: string;
|
|
64
|
+
cwd: string;
|
|
65
|
+
timestamp: string;
|
|
66
|
+
slug?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type ClaudeCodeContentBlock =
|
|
70
|
+
| { type: "text"; text: string }
|
|
71
|
+
| { type: "thinking"; thinking: string }
|
|
72
|
+
| { type: "tool_use"; [key: string]: unknown }
|
|
73
|
+
| { type: string; [key: string]: unknown };
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* summary record — compaction checkpoint with compressed history.
|
|
77
|
+
* We skip these for import (not conversational content).
|
|
78
|
+
*/
|
|
79
|
+
export interface ClaudeCodeSummaryRecord extends ClaudeCodeRecordBase {
|
|
80
|
+
type: "summary";
|
|
81
|
+
summary: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Types we explicitly skip:
|
|
85
|
+
// - file-history-snapshot: git working tree state
|
|
86
|
+
// - system: slash commands, local_command records
|
|
87
|
+
// - progress: hook progress events
|
|
88
|
+
// - tool_use / tool_result: internal tool plumbing (only in transcripts/)
|
|
89
|
+
|
|
90
|
+
export type ClaudeCodeRecord =
|
|
91
|
+
| ClaudeCodeUserRecord
|
|
92
|
+
| ClaudeCodeAssistantRecord
|
|
93
|
+
| ClaudeCodeSummaryRecord
|
|
94
|
+
| ClaudeCodeRecordBase; // catch-all for skipped types
|
|
95
|
+
|
|
96
|
+
// ============================================================================
|
|
97
|
+
// Cleaned Session / Message Types (for Ei consumption)
|
|
98
|
+
// ============================================================================
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A Claude Code session (one JSONL file = one session).
|
|
102
|
+
*/
|
|
103
|
+
export interface ClaudeCodeSession {
|
|
104
|
+
/** UUID from the filename, e.g. "0da9e1e8-187f-40f9-a66b-c7f1ebf2a72e" */
|
|
105
|
+
id: string;
|
|
106
|
+
/** Working directory when the session was started */
|
|
107
|
+
cwd: string;
|
|
108
|
+
/** Derived title: last segment of cwd (e.g. "ei" from "/Users/foo/Projects/Personal/ei") */
|
|
109
|
+
title: string;
|
|
110
|
+
/** ISO timestamp of the first message */
|
|
111
|
+
firstMessageAt: string;
|
|
112
|
+
/** ISO timestamp of the last message */
|
|
113
|
+
lastMessageAt: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A single user↔assistant exchange, cleaned for Ei.
|
|
118
|
+
*/
|
|
119
|
+
export interface ClaudeCodeMessage {
|
|
120
|
+
id: string;
|
|
121
|
+
sessionId: string;
|
|
122
|
+
role: "user" | "assistant";
|
|
123
|
+
/** Concatenated text blocks only — tool calls, thinking, snapshots are stripped */
|
|
124
|
+
content: string;
|
|
125
|
+
timestamp: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Constants
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
/** The single persona name for all Claude Code sessions */
|
|
133
|
+
export const CLAUDE_CODE_PERSONA_NAME = "Claude Code";
|
|
134
|
+
|
|
135
|
+
/** Topic groups assigned to Claude Code session topics */
|
|
136
|
+
export const CLAUDE_CODE_TOPIC_GROUPS = ["General", "Coding", "Claude Code"];
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Minimum session age before we import it.
|
|
140
|
+
* Mirrors OpenCode's 20-minute rule — gives the session time to "settle."
|
|
141
|
+
*/
|
|
142
|
+
export const MIN_SESSION_AGE_MS = 20 * 60 * 1000;
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// Human Settings Shape (mirrors OpenCodeSettings in core/types.ts)
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Stored under human.settings.claudeCode
|
|
150
|
+
*
|
|
151
|
+
* ⚠️ ADDING A NEW FIELD HERE?
|
|
152
|
+
* If it's runtime-managed (not user-editable), you MUST also add it to the
|
|
153
|
+
* claudeCode reconstruction block in settingsFromYAML() in:
|
|
154
|
+
* tui/src/util/yaml-serializers.ts
|
|
155
|
+
* Otherwise it will be silently wiped every time the user saves /settings.
|
|
156
|
+
* Same rule applies to any future integration settings (Cursor, etc.).
|
|
157
|
+
*/
|
|
158
|
+
export interface ClaudeCodeSettings {
|
|
159
|
+
integration?: boolean;
|
|
160
|
+
polling_interval_ms?: number; // Default: 1800000 (30 min)
|
|
161
|
+
last_sync?: string; // ISO timestamp
|
|
162
|
+
processed_sessions?: Record<string, string>; // sessionId → ISO timestamp of last import
|
|
163
|
+
}
|
|
@@ -32,6 +32,7 @@ export interface OpenCodeImporterOptions {
|
|
|
32
32
|
stateManager: StateManager;
|
|
33
33
|
interface?: Ei_Interface;
|
|
34
34
|
reader?: IOpenCodeReader;
|
|
35
|
+
signal?: AbortSignal;
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
// =============================================================================
|
|
@@ -94,7 +95,7 @@ function filterRelevantMessages(messages: OpenCodeMessage[]): OpenCodeMessage[]
|
|
|
94
95
|
export async function importOpenCodeSessions(
|
|
95
96
|
options: OpenCodeImporterOptions
|
|
96
97
|
): Promise<ImportResult> {
|
|
97
|
-
const { stateManager, interface: eiInterface } = options;
|
|
98
|
+
const { stateManager, interface: eiInterface, signal } = options;
|
|
98
99
|
const reader = options.reader ?? await createOpenCodeReader();
|
|
99
100
|
|
|
100
101
|
const result: ImportResult = {
|
|
@@ -110,6 +111,8 @@ export async function importOpenCodeSessions(
|
|
|
110
111
|
// Always runs (cheap), so session titles stay current regardless of
|
|
111
112
|
// whether we process messages this cycle.
|
|
112
113
|
const allSessions = await reader.getSessionsUpdatedSince(new Date(0));
|
|
114
|
+
|
|
115
|
+
if (signal?.aborted) return result;
|
|
113
116
|
const primarySessions = allSessions.filter(s => !s.parentId);
|
|
114
117
|
|
|
115
118
|
for (const session of primarySessions) {
|
|
@@ -156,6 +159,7 @@ export async function importOpenCodeSessions(
|
|
|
156
159
|
// Nothing new to process — bump last_sync and return
|
|
157
160
|
console.log(`[OpenCode] All sessions processed, nothing new since extraction_point`);
|
|
158
161
|
return result;
|
|
162
|
+
if (signal?.aborted) return result;
|
|
159
163
|
}
|
|
160
164
|
|
|
161
165
|
console.log(
|
|
@@ -171,6 +175,7 @@ export async function importOpenCodeSessions(
|
|
|
171
175
|
// Empty session — mark processed and advance
|
|
172
176
|
updateExtractionState(stateManager, targetSession);
|
|
173
177
|
return result;
|
|
178
|
+
if (signal?.aborted) return result;
|
|
174
179
|
}
|
|
175
180
|
|
|
176
181
|
// ─── Step 4: Resolve agents → personas, group by persona ID ────────
|
|
@@ -241,8 +246,10 @@ export async function importOpenCodeSessions(
|
|
|
241
246
|
messages_analyze: toAnalyze,
|
|
242
247
|
};
|
|
243
248
|
|
|
244
|
-
|
|
245
|
-
|
|
249
|
+
if (!signal?.aborted) {
|
|
250
|
+
queueAllScans(context, stateManager);
|
|
251
|
+
result.extractionScansQueued += 4;
|
|
252
|
+
}
|
|
246
253
|
}
|
|
247
254
|
}
|
|
248
255
|
|
|
@@ -138,9 +138,11 @@ ${schemaFragment}`;
|
|
|
138
138
|
userPrompt += `## User's Topics (PRESERVE EXACTLY, add more if fewer than 3)\n`;
|
|
139
139
|
for (const topic of data.existing_topics ?? []) {
|
|
140
140
|
if (topic.name?.trim()) {
|
|
141
|
-
userPrompt += `- ${topic.name}`;
|
|
142
|
-
if (topic.
|
|
143
|
-
userPrompt +=
|
|
141
|
+
userPrompt += `- ${topic.name}\n`;
|
|
142
|
+
if (topic.perspective) userPrompt += ` perspective: ${topic.perspective}\n`;
|
|
143
|
+
if (topic.approach) userPrompt += ` approach: ${topic.approach}\n`;
|
|
144
|
+
if (topic.personal_stake) userPrompt += ` personal_stake: ${topic.personal_stake}\n`;
|
|
145
|
+
if (topic.sentiment !== undefined) userPrompt += ` sentiment: ${topic.sentiment}\n`;
|
|
144
146
|
}
|
|
145
147
|
}
|
|
146
148
|
userPrompt += `\n`;
|
|
@@ -10,7 +10,7 @@ export interface PersonaGenerationPromptData {
|
|
|
10
10
|
long_description?: string;
|
|
11
11
|
short_description?: string;
|
|
12
12
|
existing_traits?: Array<{ name?: string; description?: string; sentiment?: number; strength?: number }>;
|
|
13
|
-
existing_topics?: Array<{ name?: string;
|
|
13
|
+
existing_topics?: Array<{ name?: string; perspective?: string; approach?: string; personal_stake?: string; sentiment?: number; exposure_current?: number; exposure_desired?: number }>;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
export interface PersonaGenerationResult {
|
|
@@ -98,9 +98,9 @@ The JSON format is:
|
|
|
98
98
|
{
|
|
99
99
|
"facts": [
|
|
100
100
|
{
|
|
101
|
-
"type_of_fact": "
|
|
102
|
-
"value_of_fact": "
|
|
103
|
-
"reason": "
|
|
101
|
+
"type_of_fact": "The Fact Type from above",
|
|
102
|
+
"value_of_fact": "The exact value of the fact",
|
|
103
|
+
"reason": "The justification of including this specific fact"
|
|
104
104
|
}
|
|
105
105
|
]
|
|
106
106
|
}
|
|
@@ -140,9 +140,9 @@ Scan the "Most Recent Messages" for FACTS about the human user.
|
|
|
140
140
|
{
|
|
141
141
|
"facts": [
|
|
142
142
|
{
|
|
143
|
-
"type_of_fact": "
|
|
144
|
-
"value_of_fact": "
|
|
145
|
-
"reason": "
|
|
143
|
+
"type_of_fact": "The Fact Type from above",
|
|
144
|
+
"value_of_fact": "The exact value of the fact",
|
|
145
|
+
"reason": "The justification of including this specific fact"
|
|
146
146
|
}
|
|
147
147
|
]
|
|
148
148
|
}
|
|
@@ -57,9 +57,9 @@ The JSON format is:
|
|
|
57
57
|
{
|
|
58
58
|
"people": [
|
|
59
59
|
{
|
|
60
|
-
"type_of_person": "
|
|
61
|
-
"name_of_person": "
|
|
62
|
-
"reason": "
|
|
60
|
+
"type_of_person": "The relationship from the list above",
|
|
61
|
+
"name_of_person": "The person's name",
|
|
62
|
+
"reason": "The justification of including this specific person"
|
|
63
63
|
}
|
|
64
64
|
]
|
|
65
65
|
}
|
|
@@ -97,9 +97,9 @@ Scan the "Most Recent Messages" for PEOPLE mentioned by the human user.
|
|
|
97
97
|
{
|
|
98
98
|
"people": [
|
|
99
99
|
{
|
|
100
|
-
"type_of_person": "
|
|
101
|
-
"name_of_person": "
|
|
102
|
-
"reason": "
|
|
100
|
+
"type_of_person": "The relationship from the list above",
|
|
101
|
+
"name_of_person": "The person's name",
|
|
102
|
+
"reason": "The justification of including this specific person"
|
|
103
103
|
}
|
|
104
104
|
]
|
|
105
105
|
}
|
|
@@ -81,9 +81,9 @@ The JSON format is:
|
|
|
81
81
|
{
|
|
82
82
|
"topics": [
|
|
83
83
|
{
|
|
84
|
-
"type_of_topic": "
|
|
85
|
-
"type_of_topic": "Interest|Goal|Dream|Conflict|Concern|etc.",
|
|
84
|
+
"type_of_topic": "The Topic Type from the list above",
|
|
86
85
|
"value_of_topic": "<actual topic from the conversation>",
|
|
86
|
+
"reason": "The justification of including this specific topic"
|
|
87
87
|
}
|
|
88
88
|
]
|
|
89
89
|
}
|
|
@@ -123,9 +123,9 @@ Scan the "Most Recent Messages" for TOPICS of interest to the human user.
|
|
|
123
123
|
{
|
|
124
124
|
"topics": [
|
|
125
125
|
{
|
|
126
|
-
"type_of_topic": "
|
|
126
|
+
"type_of_topic": "The Topic Type from the list above",
|
|
127
127
|
"value_of_topic": "<actual topic from the conversation>",
|
|
128
|
-
"reason": "
|
|
128
|
+
"reason": "The justification of including this specific topic"
|
|
129
129
|
}
|
|
130
130
|
]
|
|
131
131
|
}
|
|
@@ -63,9 +63,9 @@ The JSON format is:
|
|
|
63
63
|
{
|
|
64
64
|
"traits": [
|
|
65
65
|
{
|
|
66
|
-
"type_of_trait": "
|
|
67
|
-
"value_of_trait": "
|
|
68
|
-
"reason": "
|
|
66
|
+
"type_of_trait": "The type of trait from the list above",
|
|
67
|
+
"value_of_trait": "A short description of the trait",
|
|
68
|
+
"reason": "The justification of including this specific trait"
|
|
69
69
|
}
|
|
70
70
|
]
|
|
71
71
|
}
|
|
@@ -103,9 +103,9 @@ Scan the "Most Recent Messages" for TRAITS of the human user.
|
|
|
103
103
|
{
|
|
104
104
|
"traits": [
|
|
105
105
|
{
|
|
106
|
-
"type_of_trait": "
|
|
107
|
-
"value_of_trait": "
|
|
108
|
-
"reason": "
|
|
106
|
+
"type_of_trait": "The type of trait from the list above",
|
|
107
|
+
"value_of_trait": "A short description of the trait",
|
|
108
|
+
"reason": "The justification of including this specific trait"
|
|
109
109
|
}
|
|
110
110
|
]
|
|
111
111
|
}
|
|
@@ -76,8 +76,8 @@ ${formatTraitsForPrompt(data.current_traits)}
|
|
|
76
76
|
\`\`\`json
|
|
77
77
|
[
|
|
78
78
|
{
|
|
79
|
-
"name": "
|
|
80
|
-
"description": "
|
|
79
|
+
"name": "A one- or two-word Title for the trait",
|
|
80
|
+
"description": "A brief instruction on how the trait is exhibited",
|
|
81
81
|
"sentiment": 0.3,
|
|
82
82
|
"strength": 0.7
|
|
83
83
|
}
|
|
@@ -319,6 +319,20 @@ export function buildSystemKnowledgeSection(isTUI: boolean): string {
|
|
|
319
319
|
- Hover over a persona to see controls: pause, edit (Pencil), archive, delete (Trash)
|
|
320
320
|
- Click a persona to switch conversations
|
|
321
321
|
- The [+] button creates new personas`;
|
|
322
|
+
const externalImportNotes = isTUI ? `
|
|
323
|
+
|
|
324
|
+
### Coding Agent Integrations
|
|
325
|
+
Ei can silently read session histories from AI coding tools and build memories from them — so you learn who the human works with, what projects they care about, and what they've been building, without them having to relay it manually.
|
|
326
|
+
|
|
327
|
+
Both integrations are enabled here in settings. Look for the \`opencode\` or \`claudeCode\` section and set \`integration: true\`.
|
|
328
|
+
|
|
329
|
+
#### OpenCode
|
|
330
|
+
When enabled, Ei reads OpenCode's session history and builds a persona for each AI agent the human works with (Sisyphus, Oracle, etc.). Each session becomes a topic on that persona, so Ei can discuss the work in context.
|
|
331
|
+
|
|
332
|
+
The connection also runs the other direction: running \`ei --install\` in the terminal registers Ei as a tool inside both OpenCode and Claude Code at the same time. Once installed, those coding agents can query Ei's memory directly — facts, traits, topics, people, quotes — giving them persistent knowledge about the human across sessions.
|
|
333
|
+
|
|
334
|
+
#### Claude Code
|
|
335
|
+
When enabled, Ei reads Claude Code's session history (stored in \`~/.claude/projects/\`) and creates a single "Claude Code" persona representing those conversations. Sessions become topics, and Ei learns from the work without the human having to explain it.` : "";
|
|
322
336
|
|
|
323
337
|
return `# System Knowledge
|
|
324
338
|
|
|
@@ -364,6 +378,7 @@ The human can view and edit all of this by ${seeHumanDataAction}.
|
|
|
364
378
|
- Configure LLM providers (local or cloud)
|
|
365
379
|
- Set up device sync (encrypted backup to restore on other devices)
|
|
366
380
|
- Adjust ceremony timing (overnight persona evolution)
|
|
381
|
+
${externalImportNotes}
|
|
367
382
|
|
|
368
383
|
### Tips You Can Share
|
|
369
384
|
- If they want to talk to a persona privately, tell them about the "Groups" functionality
|
package/src/storage/interface.ts
CHANGED
|
@@ -6,4 +6,6 @@ export interface Storage {
|
|
|
6
6
|
load(): Promise<StorageState | null>;
|
|
7
7
|
moveToBackup(): Promise<void>;
|
|
8
8
|
loadBackup(): Promise<StorageState | null>;
|
|
9
|
+
/** Save a rolling backup of state with a local timestamp filename. Prunes oldest if over limit. */
|
|
10
|
+
saveRollingBackup(state: StorageState, maxBackups: number): Promise<void>;
|
|
9
11
|
}
|
package/src/storage/local.ts
CHANGED
|
@@ -81,4 +81,9 @@ export class LocalStorage implements Storage {
|
|
|
81
81
|
(e.name === "QuotaExceededError" || e.name === "NS_ERROR_DOM_QUOTA_REACHED")
|
|
82
82
|
);
|
|
83
83
|
}
|
|
84
|
+
/** No-op in browser — rolling backups are TUI-only (filesystem required). */
|
|
85
|
+
async saveRollingBackup(_state: StorageState, _maxBackups: number): Promise<void> {
|
|
86
|
+
// Intentional no-op: localStorage has no directory/file concept.
|
|
87
|
+
// The Processor gates this call with `this.isTUI` so it never runs in the browser.
|
|
88
|
+
}
|
|
84
89
|
}
|
package/tui/README.md
CHANGED
|
@@ -99,6 +99,19 @@ All commands start with `/`. Append `!` to any command as a shorthand for `--for
|
|
|
99
99
|
| `Ctrl+E` | Open `$EDITOR` (preserves current input) |
|
|
100
100
|
| `PageUp / PageDown` | Scroll message history |
|
|
101
101
|
|
|
102
|
+
# Environment Variables
|
|
103
|
+
|
|
104
|
+
| Variable | Default | Description |
|
|
105
|
+
|----------|---------|-------------|
|
|
106
|
+
| `EI_DATA_PATH` | `~/.local/share/ei` | Path to Ei's persistent data directory. Set this to keep multiple profiles or point to a shared/synced folder. |
|
|
107
|
+
| `XDG_DATA_HOME` | `~/.local/share` | XDG base directory. Ignored if `EI_DATA_PATH` is set. |
|
|
108
|
+
| `EI_SYNC_USERNAME` | — | Username for remote sync. If set at startup, bootstraps sync credentials automatically (useful for dotfiles/scripts). |
|
|
109
|
+
| `EI_SYNC_PASSPHRASE` | — | Passphrase for remote sync. Paired with `EI_SYNC_USERNAME`. |
|
|
110
|
+
| `EDITOR` / `VISUAL` | `vi` | Editor opened by `/details`, `/me`, `/settings`, `/context`, `/quotes`, etc. Falls back to `VISUAL` if `EDITOR` is unset. |
|
|
111
|
+
|
|
112
|
+
> **Tip**: `tail -f $EI_DATA_PATH/tui.log` to watch live debug output.
|
|
113
|
+
|
|
114
|
+
|
|
102
115
|
# Development
|
|
103
116
|
|
|
104
117
|
## Requirements
|
package/tui/src/index.tsx
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
import { render } from "@opentui/solid";
|
|
2
2
|
import { App } from "./app";
|
|
3
|
+
import { InstanceLock } from "./util/instance-lock";
|
|
4
|
+
import { FileStorage } from "./storage/file";
|
|
5
|
+
|
|
6
|
+
const storage = new FileStorage(Bun.env.EI_DATA_PATH);
|
|
7
|
+
const lock = new InstanceLock(storage.getDataPath());
|
|
8
|
+
const lockResult = await lock.acquire();
|
|
9
|
+
|
|
10
|
+
if (!lockResult.acquired) {
|
|
11
|
+
process.stderr.write(
|
|
12
|
+
`\nEi cannot start: another instance is already running.\n` +
|
|
13
|
+
` PID: ${lockResult.pid}\n` +
|
|
14
|
+
` Started: ${lockResult.started}\n` +
|
|
15
|
+
` Lock: ${storage.getDataPath()}/ei.lock\n\n` +
|
|
16
|
+
`Close the other instance first, or delete the lock file if it is stale.\n\n`
|
|
17
|
+
);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Release lock when the app exits (keyboard context calls process.exit(0) on normal quit)
|
|
22
|
+
process.on("exit", () => { void lock.release(); });
|
|
3
23
|
|
|
4
24
|
render(App, {
|
|
5
25
|
exitOnCtrlC: false,
|
package/tui/src/storage/file.ts
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import type { StorageState } from "../../../src/core/types";
|
|
2
2
|
import type { Storage } from "../../../src/storage/interface";
|
|
3
3
|
import { join } from "path";
|
|
4
|
-
import { mkdir, rename, unlink } from "fs/promises";
|
|
4
|
+
import { mkdir, rename, unlink, readdir } from "fs/promises";
|
|
5
5
|
|
|
6
6
|
const STATE_FILE = "state.json";
|
|
7
7
|
const BACKUP_FILE = "state.backup.json";
|
|
8
|
+
const BACKUPS_DIR = "backups";
|
|
8
9
|
const LOCK_TIMEOUT_MS = 5000;
|
|
9
10
|
const LOCK_RETRY_DELAY_MS = 50;
|
|
10
11
|
|
|
11
12
|
export class FileStorage implements Storage {
|
|
12
|
-
private dataPath: string;
|
|
13
|
+
private readonly dataPath: string;
|
|
13
14
|
|
|
14
15
|
constructor(dataPath?: string) {
|
|
15
16
|
if (dataPath) {
|
|
@@ -22,6 +23,10 @@ export class FileStorage implements Storage {
|
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
getDataPath(): string {
|
|
27
|
+
return this.dataPath;
|
|
28
|
+
}
|
|
29
|
+
|
|
25
30
|
async isAvailable(): Promise<boolean> {
|
|
26
31
|
try {
|
|
27
32
|
await this.ensureDataDir();
|
|
@@ -101,6 +106,38 @@ export class FileStorage implements Storage {
|
|
|
101
106
|
|
|
102
107
|
return null;
|
|
103
108
|
}
|
|
109
|
+
async saveRollingBackup(state: StorageState, maxBackups: number): Promise<void> {
|
|
110
|
+
const backupsPath = join(this.dataPath, BACKUPS_DIR);
|
|
111
|
+
await mkdir(backupsPath, { recursive: true });
|
|
112
|
+
|
|
113
|
+
// Filename is local timestamp: YYYY-MM-DDTHH-MM-SS (colons replaced for FS compat)
|
|
114
|
+
const now = new Date();
|
|
115
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
116
|
+
const name = [
|
|
117
|
+
now.getFullYear(),
|
|
118
|
+
"-", pad(now.getMonth() + 1),
|
|
119
|
+
"-", pad(now.getDate()),
|
|
120
|
+
"T", pad(now.getHours()),
|
|
121
|
+
"-", pad(now.getMinutes()),
|
|
122
|
+
"-", pad(now.getSeconds()),
|
|
123
|
+
].join("") + ".json";
|
|
124
|
+
|
|
125
|
+
const destPath = join(backupsPath, name);
|
|
126
|
+
await this.atomicWrite(destPath, JSON.stringify(state, null, 2));
|
|
127
|
+
|
|
128
|
+
// Prune: keep only the newest maxBackups files
|
|
129
|
+
const entries = await readdir(backupsPath);
|
|
130
|
+
const jsonFiles = entries
|
|
131
|
+
.filter(f => f.endsWith(".json"))
|
|
132
|
+
.sort(); // ISO-like names sort chronologically
|
|
133
|
+
|
|
134
|
+
const excess = jsonFiles.length - maxBackups;
|
|
135
|
+
if (excess > 0) {
|
|
136
|
+
for (const old of jsonFiles.slice(0, excess)) {
|
|
137
|
+
await unlink(join(backupsPath, old));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
104
141
|
|
|
105
142
|
private async ensureDataDir(): Promise<void> {
|
|
106
143
|
try {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { join } from "path";
|
|
2
|
+
import { unlink } from "fs/promises";
|
|
3
|
+
|
|
4
|
+
const LOCK_FILE = "ei.lock";
|
|
5
|
+
|
|
6
|
+
export interface LockData {
|
|
7
|
+
pid: number;
|
|
8
|
+
started: string;
|
|
9
|
+
frontend: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type AcquireResult =
|
|
13
|
+
| { acquired: true }
|
|
14
|
+
| { acquired: false; reason: "live_process"; pid: number; started: string };
|
|
15
|
+
|
|
16
|
+
export class InstanceLock {
|
|
17
|
+
private lockPath: string;
|
|
18
|
+
private held = false;
|
|
19
|
+
|
|
20
|
+
constructor(dataPath: string) {
|
|
21
|
+
this.lockPath = join(dataPath, LOCK_FILE);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Try to acquire the instance lock.
|
|
26
|
+
*
|
|
27
|
+
* - No lock file → write and proceed.
|
|
28
|
+
* - Lock file exists, PID is dead → steal and proceed.
|
|
29
|
+
* - Lock file exists, PID is live → return { acquired: false }.
|
|
30
|
+
*/
|
|
31
|
+
async acquire(): Promise<AcquireResult> {
|
|
32
|
+
const existing = await this.readLock();
|
|
33
|
+
|
|
34
|
+
if (existing) {
|
|
35
|
+
const alive = isProcessAlive(existing.pid);
|
|
36
|
+
if (alive) {
|
|
37
|
+
return { acquired: false, reason: "live_process", pid: existing.pid, started: existing.started };
|
|
38
|
+
}
|
|
39
|
+
// Stale lock — fall through and overwrite
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
await this.writeLock();
|
|
43
|
+
this.held = true;
|
|
44
|
+
return { acquired: true };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Release the lock. Safe to call multiple times / if never acquired.
|
|
49
|
+
*/
|
|
50
|
+
async release(): Promise<void> {
|
|
51
|
+
if (!this.held) return;
|
|
52
|
+
this.held = false;
|
|
53
|
+
try {
|
|
54
|
+
await unlink(this.lockPath);
|
|
55
|
+
} catch {
|
|
56
|
+
// Already gone — that's fine
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async readLock(): Promise<LockData | null> {
|
|
61
|
+
try {
|
|
62
|
+
const file = Bun.file(this.lockPath);
|
|
63
|
+
if (!(await file.exists())) return null;
|
|
64
|
+
const text = await file.text();
|
|
65
|
+
return JSON.parse(text) as LockData;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private async writeLock(): Promise<void> {
|
|
72
|
+
const data: LockData = {
|
|
73
|
+
pid: process.pid,
|
|
74
|
+
started: new Date().toISOString(),
|
|
75
|
+
frontend: "tui",
|
|
76
|
+
};
|
|
77
|
+
await Bun.write(this.lockPath, JSON.stringify(data, null, 2));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Check whether a process with the given PID is currently running.
|
|
83
|
+
* Uses kill(pid, 0) — sends no signal, just checks existence.
|
|
84
|
+
*/
|
|
85
|
+
function isProcessAlive(pid: number): boolean {
|
|
86
|
+
try {
|
|
87
|
+
process.kill(pid, 0);
|
|
88
|
+
return true;
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
package/tui/src/util/logger.ts
CHANGED
|
@@ -46,9 +46,7 @@ function formatMessage(level: LogLevel, message: string, data?: unknown): string
|
|
|
46
46
|
|
|
47
47
|
function writeLogSync(level: LogLevel, message: string, data?: unknown): void {
|
|
48
48
|
if (!shouldLog(level)) return;
|
|
49
|
-
|
|
50
49
|
const line = formatMessage(level, message, data);
|
|
51
|
-
|
|
52
50
|
try {
|
|
53
51
|
appendFileSync(getLogPath(), line);
|
|
54
52
|
} catch {}
|