ei-tui 0.1.14 → 0.1.17

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.14",
3
+ "version": "0.1.17",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,5 @@
1
1
  import type { StorageState, Quote, Fact, Trait, Person, Topic } from "../core/types";
2
+ import { decodeAllEmbeddings } from "../storage/embeddings";
2
3
  import { crossFind } from "../core/utils/index.ts";
3
4
  import { join } from "path";
4
5
  import { readFile } from "fs/promises";
@@ -21,7 +22,7 @@ export async function loadLatestState(): Promise<StorageState | null> {
21
22
  for (const file of [STATE_FILE, BACKUP_FILE]) {
22
23
  try {
23
24
  const text = await readFile(join(dataPath, file), "utf-8");
24
- if (text) return JSON.parse(text) as StorageState;
25
+ if (text) return decodeAllEmbeddings(JSON.parse(text) as StorageState);
25
26
  } catch {
26
27
  continue;
27
28
  }
@@ -292,11 +292,14 @@ export class Processor {
292
292
  const result = await remoteSync.sync(state);
293
293
 
294
294
  if (!result.success) {
295
- // Push failed likely 412 etag mismatch or network error
296
- // Do NOT moveToBackup — leave state.json intact
297
- // Next boot will detect primary + remote → conflict resolution
295
+ // Sync failed (e.g. 429, network error, 412 etag mismatch).
296
+ // Do NOT stop() — leave the processor loop running so the user can
297
+ // keep using the TUI and retry /quit later.
298
+ // Do NOT moveToBackup — leave state.json intact so next boot can
299
+ // detect primary + remote → conflict resolution if needed.
298
300
  console.log(`[Processor ${this.instanceId}] Remote sync failed: ${result.error}`);
299
- await this.stop();
301
+ // Reset the import abort controller so imports can resume normally.
302
+ this.importAbortController = new AbortController();
300
303
  this.interface.onSaveAndExitFinish?.();
301
304
  return { success: false, error: result.error };
302
305
  }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Embedding serialization utilities.
3
+ *
4
+ * At runtime, embeddings are `number[]` (384-dim float vectors).
5
+ * In storage, they are base64-encoded Float32Array binary blobs — identical data,
6
+ * ~75% smaller than JSON float arrays, and still compressible by gzip for sync/LocalStorage.
7
+ *
8
+ * Format on disk/in LocalStorage:
9
+ * "embedding": "AAAAAAAA..." // btoa(Float32Array.buffer)
10
+ *
11
+ * Format in memory (unchanged — nothing outside storage layer sees strings):
12
+ * embedding: [0.0234567, -0.0891234, ...]
13
+ *
14
+ * Backward compatibility: if a stored embedding is already a number[] (old format),
15
+ * decodeEmbedding returns it as-is. Mixed old/new files are handled transparently.
16
+ *
17
+ * IMPORTANT: encodeAllEmbeddings does NOT mutate the input state. It returns a new
18
+ * StorageState where human item arrays are shallow-copied with encoded embedding fields.
19
+ * This prevents the live in-memory state from being corrupted with base64 strings.
20
+ */
21
+
22
+ import type { StorageState } from "../core/types.js";
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Encode: number[] → base64 string
26
+ // ---------------------------------------------------------------------------
27
+
28
+ function encodeEmbedding(embedding: number[]): string {
29
+ const buffer = new Float32Array(embedding).buffer;
30
+ const bytes = new Uint8Array(buffer);
31
+ let binary = "";
32
+ for (let i = 0; i < bytes.length; i++) {
33
+ binary += String.fromCharCode(bytes[i]);
34
+ }
35
+ return btoa(binary);
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Decode: base64 string → number[] (no-op if already number[])
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function decodeEmbedding(value: unknown): number[] | undefined {
43
+ if (value == null) return undefined;
44
+ if (Array.isArray(value)) return value as number[]; // backward compat
45
+ if (typeof value !== "string") return undefined;
46
+
47
+ const binary = atob(value);
48
+ const bytes = new Uint8Array(binary.length);
49
+ for (let i = 0; i < binary.length; i++) {
50
+ bytes[i] = binary.charCodeAt(i);
51
+ }
52
+ return Array.from(new Float32Array(bytes.buffer));
53
+ }
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // Walk the entire StorageState and encode/decode all embedding fields
57
+ // ---------------------------------------------------------------------------
58
+
59
+ const HUMAN_ITEM_KEYS = ["facts", "traits", "topics", "people", "quotes"] as const;
60
+
61
+ /**
62
+ * Returns a new StorageState with embeddings encoded as base64 strings.
63
+ * Does NOT mutate the input — human item arrays are shallow-copied.
64
+ */
65
+ export function encodeAllEmbeddings(state: StorageState): StorageState {
66
+ const human = (state as unknown as Record<string, unknown>)["human"] as Record<string, unknown> | undefined;
67
+ if (!human) return state;
68
+
69
+ const encodedHuman: Record<string, unknown> = { ...human };
70
+ for (const key of HUMAN_ITEM_KEYS) {
71
+ const items = human[key];
72
+ if (Array.isArray(items)) {
73
+ encodedHuman[key] = items.map((item: Record<string, unknown>) => {
74
+ if (!Array.isArray(item.embedding) || item.embedding.length === 0) return item;
75
+ return { ...item, embedding: encodeEmbedding(item.embedding as number[]) };
76
+ });
77
+ }
78
+ }
79
+
80
+ return { ...state, human: encodedHuman as unknown as StorageState["human"] };
81
+ }
82
+
83
+ /**
84
+ * Returns a new StorageState with embeddings decoded from base64 to number[].
85
+ * Does NOT mutate the input — human item arrays are shallow-copied.
86
+ */
87
+ export function decodeAllEmbeddings(state: StorageState): StorageState {
88
+ const human = (state as unknown as Record<string, unknown>)["human"] as Record<string, unknown> | undefined;
89
+ if (!human) return state;
90
+
91
+ const decodedHuman: Record<string, unknown> = { ...human };
92
+ for (const key of HUMAN_ITEM_KEYS) {
93
+ const items = human[key];
94
+ if (Array.isArray(items)) {
95
+ decodedHuman[key] = items.map((item: Record<string, unknown>) => {
96
+ if (item.embedding === undefined || Array.isArray(item.embedding)) return item;
97
+ return { ...item, embedding: decodeEmbedding(item.embedding) };
98
+ });
99
+ }
100
+ }
101
+
102
+ return { ...state, human: decodedHuman as unknown as StorageState["human"] };
103
+ }
@@ -1,6 +1,7 @@
1
1
  import type { StorageState } from "../core/types.js";
2
2
  import type { Storage } from "./interface.js";
3
3
  import { compress, decompress, isCompressed } from "./compress.js";
4
+ import { encodeAllEmbeddings, decodeAllEmbeddings } from "./embeddings.js";
4
5
 
5
6
  const STATE_KEY = "ei_state";
6
7
  const BACKUP_KEY = "ei_state_backup";
@@ -20,7 +21,7 @@ export class LocalStorage implements Storage {
20
21
  async save(state: StorageState): Promise<void> {
21
22
  state.timestamp = new Date().toISOString();
22
23
  try {
23
- const json = JSON.stringify(state);
24
+ const json = JSON.stringify(encodeAllEmbeddings(state));
24
25
  const payload = await compress(json);
25
26
  globalThis.localStorage.setItem(STATE_KEY, payload);
26
27
  } catch (e) {
@@ -36,7 +37,7 @@ export class LocalStorage implements Storage {
36
37
  if (current) {
37
38
  try {
38
39
  const json = isCompressed(current) ? await decompress(current) : current;
39
- return JSON.parse(json) as StorageState;
40
+ return decodeAllEmbeddings(JSON.parse(json) as StorageState);
40
41
  } catch {
41
42
  return null;
42
43
  }
@@ -52,8 +53,9 @@ export class LocalStorage implements Storage {
52
53
  async moveToBackup(): Promise<void> {
53
54
  const current = globalThis.localStorage?.getItem(STATE_KEY);
54
55
  if (current) {
55
- globalThis.localStorage.setItem(BACKUP_KEY, current);
56
+ // Remove primary first so backup write doesn't double-count against quota.
56
57
  globalThis.localStorage.removeItem(STATE_KEY);
58
+ globalThis.localStorage.setItem(BACKUP_KEY, current);
57
59
  }
58
60
  }
59
61
 
@@ -67,7 +69,7 @@ export class LocalStorage implements Storage {
67
69
  if (backup) {
68
70
  try {
69
71
  const json = isCompressed(backup) ? await decompress(backup) : backup;
70
- return JSON.parse(json) as StorageState;
72
+ return decodeAllEmbeddings(JSON.parse(json) as StorageState);
71
73
  } catch {
72
74
  return null;
73
75
  }
@@ -56,6 +56,7 @@ export const KeyboardProvider: ParentComponent = (props) => {
56
56
  const toggleSidebar = () => setSidebarVisible(!sidebarVisible());
57
57
 
58
58
  const exitApp = async () => {
59
+ showNotification("Saving and syncing...", "info");
59
60
  const result = await saveAndExit();
60
61
  if (!result.success) {
61
62
  showNotification(`Sync failed: ${result.error}. Use /quit force to exit anyway.`, "error");
@@ -1,5 +1,6 @@
1
1
  import type { StorageState } from "../../../src/core/types";
2
2
  import type { Storage } from "../../../src/storage/interface";
3
+ import { encodeAllEmbeddings, decodeAllEmbeddings } from "../../../src/storage/embeddings";
3
4
  import { join } from "path";
4
5
  import { mkdir, rename, unlink, readdir } from "fs/promises";
5
6
 
@@ -45,7 +46,7 @@ export class FileStorage implements Storage {
45
46
 
46
47
  await this.withLock(filePath, async () => {
47
48
  try {
48
- await this.atomicWrite(filePath, JSON.stringify(state, null, 2));
49
+ await this.atomicWrite(filePath, JSON.stringify(encodeAllEmbeddings(state), null, 2));
49
50
  } catch (e) {
50
51
  if (this.isQuotaError(e)) {
51
52
  throw new Error("STORAGE_SAVE_FAILED: Disk quota exceeded");
@@ -63,7 +64,7 @@ export class FileStorage implements Storage {
63
64
  try {
64
65
  const text = await file.text();
65
66
  if (text) {
66
- return JSON.parse(text) as StorageState;
67
+ return decodeAllEmbeddings(JSON.parse(text) as StorageState);
67
68
  }
68
69
  } catch {
69
70
  return null;
@@ -96,7 +97,7 @@ export class FileStorage implements Storage {
96
97
  try {
97
98
  const text = await backupFile.text();
98
99
  if (text) {
99
- return JSON.parse(text) as StorageState;
100
+ return decodeAllEmbeddings(JSON.parse(text) as StorageState);
100
101
  }
101
102
  } catch {
102
103
  return null;
@@ -122,7 +123,7 @@ export class FileStorage implements Storage {
122
123
  ].join("") + ".json";
123
124
 
124
125
  const destPath = join(backupsPath, name);
125
- await this.atomicWrite(destPath, JSON.stringify(state, null, 2));
126
+ await this.atomicWrite(destPath, JSON.stringify(encodeAllEmbeddings(state), null, 2));
126
127
 
127
128
  // Prune: keep only the newest maxBackups files
128
129
  const entries = await readdir(backupsPath);
@@ -451,10 +451,13 @@ interface EditableSettingsData {
451
451
  opencode?: {
452
452
  integration?: boolean | null;
453
453
  polling_interval_ms?: number | null;
454
+ last_sync?: string | null;
455
+ extraction_point?: string | null;
454
456
  };
455
457
  claudeCode?: {
456
458
  integration?: boolean | null;
457
459
  polling_interval_ms?: number | null;
460
+ last_sync?: string | null;
458
461
  };
459
462
  backup?: {
460
463
  enabled?: boolean | null;
@@ -478,10 +481,13 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
478
481
  opencode: {
479
482
  integration: settings?.opencode?.integration ?? false,
480
483
  polling_interval_ms: settings?.opencode?.polling_interval_ms ?? 1800000,
484
+ last_sync: settings?.opencode?.last_sync ?? null,
485
+ extraction_point: settings?.opencode?.extraction_point ?? null,
481
486
  },
482
487
  claudeCode: {
483
488
  integration: settings?.claudeCode?.integration ?? false,
484
489
  polling_interval_ms: settings?.claudeCode?.polling_interval_ms ?? 1800000,
490
+ last_sync: settings?.claudeCode?.last_sync ?? null,
485
491
  },
486
492
  backup: {
487
493
  enabled: settings?.backup?.enabled ?? false,
@@ -492,9 +498,10 @@ export function settingsToYAML(settings: HumanSettings | undefined): string {
492
498
 
493
499
  return YAML.stringify(data, {
494
500
  lineWidth: 0,
495
- });
501
+ })
502
+ .replace(/^(\s+)(last_sync: .+)$/mg, '$1# [read-only] $2')
503
+ .replace(/^(\s+)(extraction_point: .+)$/mg, '$1# [read-only] $2');
496
504
  }
497
-
498
505
  export function settingsFromYAML(yamlContent: string, original: HumanSettings | undefined): HumanSettings {
499
506
  const data = YAML.parse(yamlContent) as EditableSettingsData;
500
507