ei-tui 0.1.14 → 0.1.16

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.16",
4
4
  "author": "Flare576",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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);