ei-tui 0.1.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ei-tui",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -131,6 +131,8 @@ export class Processor {
131
131
  private lastClaudeCodeSync = 0;
132
132
  private claudeCodeImportInProgress = false;
133
133
  private pendingConflict: StateConflictData | null = null;
134
+ private storage: Storage | null = null;
135
+ private importAbortController = new AbortController();
134
136
 
135
137
  constructor(ei: Ei_Interface) {
136
138
  this.interface = ei;
@@ -150,6 +152,7 @@ export class Processor {
150
152
 
151
153
  async start(storage: Storage): Promise<void> {
152
154
  console.log(`[Processor ${this.instanceId}] start() called`);
155
+ this.storage = storage;
153
156
  await this.stateManager.initialize(storage);
154
157
  if (this.stopped) {
155
158
  console.log(`[Processor ${this.instanceId}] stopped during init, not starting loop`);
@@ -258,6 +261,7 @@ export class Processor {
258
261
  }
259
262
 
260
263
  this.running = false;
264
+ this.importAbortController.abort();
261
265
  this.queueProcessor.abort();
262
266
  await this.stateManager.flush();
263
267
  console.log(`[Processor ${this.instanceId}] stopped`);
@@ -268,6 +272,7 @@ export class Processor {
268
272
  this.interface.onSaveAndExitStart?.();
269
273
 
270
274
  this.queueProcessor.abort();
275
+ this.importAbortController.abort();
271
276
  if (this.openCodeImportInProgress) {
272
277
  console.log(`[Processor ${this.instanceId}] Aborting OpenCode import in progress`);
273
278
  this.openCodeImportInProgress = false;
@@ -332,6 +337,7 @@ export class Processor {
332
337
  }
333
338
 
334
339
  this.pendingConflict = null;
340
+ this.importAbortController = new AbortController();
335
341
  this.running = true;
336
342
  this.runLoop();
337
343
  this.interface.onStateImported?.();
@@ -391,6 +397,9 @@ export class Processor {
391
397
  await this.checkAndSyncOpenCode(human, now);
392
398
  }
393
399
 
400
+ if (this.isTUI && human.settings?.backup?.enabled) {
401
+ await this.checkAndRunRollingBackup(human, now);
402
+ }
394
403
  if (this.isTUI && human.settings?.claudeCode?.integration && this.stateManager.queue_length() === 0) {
395
404
  await this.checkAndSyncClaudeCode(human, now);
396
405
  }
@@ -436,6 +445,33 @@ export class Processor {
436
445
  }
437
446
  }
438
447
 
448
+ private async checkAndRunRollingBackup(human: HumanEntity, now: number): Promise<void> {
449
+ if (!this.storage) return;
450
+ const cfg = human.settings!.backup!;
451
+ const intervalMs = cfg.interval_ms ?? 3_600_000; // default: 1 hour
452
+ const maxBackups = cfg.max_backups ?? 24;
453
+ const lastBackup = cfg.last_backup ? new Date(cfg.last_backup).getTime() : 0;
454
+
455
+ if (now - lastBackup < intervalMs) return;
456
+
457
+ // Update timestamp BEFORE async work to prevent duplicate triggers
458
+ this.stateManager.setHuman({
459
+ ...this.stateManager.getHuman(),
460
+ settings: {
461
+ ...this.stateManager.getHuman().settings,
462
+ backup: { ...cfg, last_backup: new Date(now).toISOString() },
463
+ },
464
+ });
465
+
466
+ const state = this.stateManager.getStorageState();
467
+ try {
468
+ await this.storage.saveRollingBackup(state, maxBackups);
469
+ console.log(`[Processor] Rolling backup saved (max=${maxBackups})`);
470
+ } catch (err) {
471
+ console.warn(`[Processor] Rolling backup failed:`, err);
472
+ }
473
+ }
474
+
439
475
  private async checkAndSyncOpenCode(human: HumanEntity, now: number): Promise<void> {
440
476
  if (this.openCodeImportInProgress) {
441
477
  return;
@@ -471,6 +507,7 @@ export class Processor {
471
507
  importOpenCodeSessions({
472
508
  stateManager: this.stateManager,
473
509
  interface: this.interface,
510
+ signal: this.importAbortController.signal,
474
511
  })
475
512
  )
476
513
  .then((result) => {
@@ -525,6 +562,7 @@ export class Processor {
525
562
  importClaudeCodeSessions({
526
563
  stateManager: this.stateManager,
527
564
  interface: this.interface,
565
+ signal: this.importAbortController.signal,
528
566
  })
529
567
  )
530
568
  .then((result) => {
package/src/core/types.ts CHANGED
@@ -192,6 +192,13 @@ export interface CeremonyConfig {
192
192
  dedup_threshold?: number; // Cosine similarity threshold for dedup candidates. Default: 0.85
193
193
  }
194
194
 
195
+ export interface BackupConfig {
196
+ enabled?: boolean; // Default: false (opt-in)
197
+ max_backups?: number; // Default: 24
198
+ interval_ms?: number; // Default: 3600000 (1 hour)
199
+ last_backup?: string; // ISO timestamp of last backup run
200
+ }
201
+
195
202
  export interface HumanSettings {
196
203
  default_model?: string;
197
204
  oneshot_model?: string; // Model for AI-assist (wand) requests; falls back to default_model
@@ -203,6 +210,7 @@ export interface HumanSettings {
203
210
  sync?: SyncCredentials;
204
211
  opencode?: OpenCodeSettings;
205
212
  ceremony?: CeremonyConfig;
213
+ backup?: BackupConfig;
206
214
  claudeCode?: import("../integrations/claude-code/types.js").ClaudeCodeSettings;
207
215
  }
208
216
 
@@ -29,6 +29,7 @@ export interface ClaudeCodeImporterOptions {
29
29
  stateManager: StateManager;
30
30
  interface?: Ei_Interface;
31
31
  reader?: IClaudeCodeReader;
32
+ signal?: AbortSignal;
32
33
  }
33
34
 
34
35
  // =============================================================================
@@ -183,7 +184,7 @@ function updateProcessedState(
183
184
  export async function importClaudeCodeSessions(
184
185
  options: ClaudeCodeImporterOptions
185
186
  ): Promise<ClaudeCodeImportResult> {
186
- const { stateManager, interface: eiInterface } = options;
187
+ const { stateManager, interface: eiInterface, signal } = options;
187
188
  const reader = options.reader ?? new ClaudeCodeReader();
188
189
 
189
190
  const result: ClaudeCodeImportResult = {
@@ -204,6 +205,7 @@ export async function importClaudeCodeSessions(
204
205
  else if (topicResult === "updated") result.topicsUpdated++;
205
206
  }
206
207
 
208
+ if (signal?.aborted) return result;
207
209
  if (result.topicsCreated > 0 || result.topicsUpdated > 0) {
208
210
  eiInterface?.onHumanUpdated?.();
209
211
  }
@@ -240,6 +242,8 @@ export async function importClaudeCodeSessions(
240
242
  return result;
241
243
  }
242
244
 
245
+ if (signal?.aborted) return result;
246
+
243
247
  console.log(
244
248
  `[ClaudeCode] Processing session: "${targetSession.title}" ` +
245
249
  `(last message: ${targetSession.lastMessageAt})`
@@ -253,6 +257,8 @@ export async function importClaudeCodeSessions(
253
257
  return result;
254
258
  }
255
259
 
260
+ if (signal?.aborted) return result;
261
+
256
262
  // ─── Step 4: Ensure persona, archive, clear, write messages ──────────
257
263
  const persona = ensureClaudeCodePersona(stateManager, eiInterface);
258
264
  result.personaCreated = !stateManager.persona_getByName(CLAUDE_CODE_PERSONA_NAME);
@@ -286,7 +292,7 @@ export async function importClaudeCodeSessions(
286
292
  eiInterface?.onMessageAdded?.(persona.id);
287
293
 
288
294
  // ─── Step 5: Queue extraction for new messages ────────────────────────
289
- if (toAnalyze.length > 0) {
295
+ if (toAnalyze.length > 0 && !signal?.aborted) {
290
296
  const allInState = stateManager.messages_get(persona.id);
291
297
  const analyzeIds = new Set(toAnalyze.map((m) => m.id));
292
298
  const analyzeStartIndex = allInState.findIndex((m) => analyzeIds.has(m.id));
@@ -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
- queueAllScans(context, stateManager);
245
- result.extractionScansQueued += 4;
249
+ if (!signal?.aborted) {
250
+ queueAllScans(context, stateManager);
251
+ result.extractionScansQueued += 4;
252
+ }
246
253
  }
247
254
  }
248
255
 
@@ -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
@@ -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
  }
@@ -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/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,
@@ -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
+ }
@@ -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 {}
@@ -5,6 +5,7 @@ import type {
5
5
  HumanSettings,
6
6
  CeremonyConfig,
7
7
  OpenCodeSettings,
8
+ BackupConfig,
8
9
  Fact,
9
10
  Trait,
10
11
  Topic,
@@ -455,6 +456,11 @@ interface EditableSettingsData {
455
456
  integration?: boolean | null;
456
457
  polling_interval_ms?: number | null;
457
458
  };
459
+ backup?: {
460
+ enabled?: boolean | null;
461
+ max_backups?: number | null;
462
+ interval_ms?: number | null;
463
+ };
458
464
  }
459
465
 
460
466
  export function settingsToYAML(settings: HumanSettings | undefined): string {
@@ -477,6 +483,11 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
477
483
  integration: settings?.claudeCode?.integration ?? false,
478
484
  polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 1800000,
479
485
  },
486
+ backup: {
487
+ enabled: settings?.backup?.enabled ?? false,
488
+ max_backups: settings?.backup?.max_backups ?? 24,
489
+ interval_ms: settings?.backup?.interval_ms ?? 3600000,
490
+ },
480
491
  };
481
492
 
482
493
  return YAML.stringify(data, {
@@ -521,6 +532,16 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
521
532
  processed_sessions: original?.claudeCode?.processed_sessions,
522
533
  };
523
534
  }
535
+
536
+ let backup: BackupConfig | undefined;
537
+ if (data.backup) {
538
+ backup = {
539
+ enabled: nullToUndefined(data.backup.enabled),
540
+ max_backups: nullToUndefined(data.backup.max_backups),
541
+ interval_ms: nullToUndefined(data.backup.interval_ms),
542
+ last_backup: original?.backup?.last_backup,
543
+ };
544
+ }
524
545
 
525
546
  return {
526
547
  ...original,
@@ -530,6 +551,7 @@ export function settingsFromYAML(yamlContent: string, original: HumanSettings |
530
551
  ceremony,
531
552
  opencode,
532
553
  claudeCode,
554
+ backup,
533
555
  };
534
556
  }
535
557