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 +1 -1
- package/src/cli/retrieval.ts +2 -1
- package/src/core/processor.ts +7 -4
- package/src/storage/embeddings.ts +103 -0
- package/src/storage/local.ts +6 -4
- package/tui/src/context/keyboard.tsx +1 -0
- package/tui/src/storage/file.ts +5 -4
- package/tui/src/util/yaml-serializers.ts +9 -2
package/package.json
CHANGED
package/src/cli/retrieval.ts
CHANGED
|
@@ -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
|
}
|
package/src/core/processor.ts
CHANGED
|
@@ -292,11 +292,14 @@ export class Processor {
|
|
|
292
292
|
const result = await remoteSync.sync(state);
|
|
293
293
|
|
|
294
294
|
if (!result.success) {
|
|
295
|
-
//
|
|
296
|
-
// Do NOT
|
|
297
|
-
//
|
|
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
|
-
|
|
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
|
+
}
|
package/src/storage/local.ts
CHANGED
|
@@ -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
|
-
|
|
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");
|
package/tui/src/storage/file.ts
CHANGED
|
@@ -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
|
|