ei-tui 0.9.4 → 1.0.1
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/README.md +22 -3
- package/package.json +5 -1
- package/src/README.md +9 -25
- package/src/core/handlers/document-segmentation.ts +113 -0
- package/src/core/handlers/human-extraction.ts +16 -16
- package/src/core/handlers/index.ts +2 -0
- package/src/core/handlers/rewrite.ts +13 -9
- package/src/core/heartbeat-manager.ts +2 -2
- package/src/core/llm-client.ts +66 -6
- package/src/core/message-manager.ts +20 -18
- package/src/core/orchestrators/ceremony.ts +83 -40
- package/src/core/orchestrators/human-extraction.ts +5 -1
- package/src/core/persona-manager.ts +4 -0
- package/src/core/processor.ts +90 -1
- package/src/core/queue-manager.ts +35 -0
- package/src/core/queue-processor.ts +13 -13
- package/src/core/state/queue.ts +9 -1
- package/src/core/state-manager.ts +10 -6
- package/src/core/types/entities.ts +15 -0
- package/src/core/types/enums.ts +1 -0
- package/src/core/types/integrations.ts +2 -0
- package/src/core/types/llm.ts +9 -0
- package/src/integrations/document/chunker.ts +88 -0
- package/src/integrations/document/importer.ts +82 -0
- package/src/integrations/document/index.ts +2 -0
- package/src/integrations/document/invoice.ts +63 -0
- package/src/integrations/document/types.ts +16 -0
- package/src/integrations/document/unsource.ts +164 -0
- package/src/integrations/persona-history/importer.ts +197 -0
- package/src/integrations/persona-history/index.ts +3 -0
- package/src/integrations/persona-history/types.ts +7 -0
- package/src/prompts/ceremony/dedup.ts +7 -3
- package/src/prompts/ceremony/index.ts +2 -1
- package/src/prompts/ceremony/people-rewrite.ts +190 -0
- package/src/prompts/ceremony/{rewrite.ts → topic-rewrite.ts} +103 -78
- package/src/prompts/human/person-scan.ts +13 -4
- package/src/prompts/human/topic-scan.ts +16 -2
- package/src/prompts/human/topic-update.ts +36 -4
- package/src/prompts/human/types.ts +1 -0
- package/src/storage/indexed.ts +4 -0
- package/src/storage/interface.ts +1 -0
- package/src/storage/local.ts +4 -0
- package/src/templates/emmett.ts +49 -0
- package/tui/README.md +25 -2
- package/tui/src/app.tsx +9 -6
- package/tui/src/commands/delete.tsx +7 -1
- package/tui/src/commands/import.tsx +30 -0
- package/tui/src/commands/unsource.tsx +115 -0
- package/tui/src/components/PromptInput.tsx +4 -0
- package/tui/src/components/WelcomeOverlay.tsx +58 -32
- package/tui/src/context/ei.tsx +80 -60
- package/tui/src/index.tsx +14 -0
- package/tui/src/storage/file.ts +11 -5
- package/tui/src/util/e2e-flags.ts +4 -3
- package/tui/src/util/help-content.ts +20 -0
- package/tui/src/util/logger.ts +1 -1
- package/tui/src/util/provider-detection.ts +251 -0
- package/tui/src/util/yaml-human.ts +7 -1
package/tui/src/context/ei.tsx
CHANGED
|
@@ -14,7 +14,13 @@ import { Processor } from "../../../src/core/processor.js";
|
|
|
14
14
|
import { FileStorage } from "../storage/file.js";
|
|
15
15
|
import { remoteSync } from "../../../src/storage/remote.js";
|
|
16
16
|
import { logger, clearLog, interceptConsole } from "../util/logger.js";
|
|
17
|
-
import { E2E_SKIP_LOCAL_DETECT } from "../util/e2e-flags.js";
|
|
17
|
+
import { E2E_SKIP_LOCAL_DETECT, E2E_SKIP_CLOUD_DETECT } from "../util/e2e-flags.js";
|
|
18
|
+
import {
|
|
19
|
+
detectProviders,
|
|
20
|
+
buildProviderAccounts,
|
|
21
|
+
ALL_PROVIDER_NAMES,
|
|
22
|
+
} from "../util/provider-detection.js";
|
|
23
|
+
import type { ProviderDetectionStatus } from "../util/provider-detection.js";
|
|
18
24
|
import { ConflictOverlay } from "../components/ConflictOverlay.js";
|
|
19
25
|
import type {
|
|
20
26
|
Ei_Interface,
|
|
@@ -28,8 +34,6 @@ import type {
|
|
|
28
34
|
Topic,
|
|
29
35
|
Person,
|
|
30
36
|
Quote,
|
|
31
|
-
ProviderAccount,
|
|
32
|
-
ProviderType,
|
|
33
37
|
StateConflictData,
|
|
34
38
|
StateConflictResolution,
|
|
35
39
|
ContextStatus,
|
|
@@ -109,6 +113,8 @@ export interface EiContextValue {
|
|
|
109
113
|
}>;
|
|
110
114
|
showWelcomeOverlay: () => boolean;
|
|
111
115
|
dismissWelcomeOverlay: () => void;
|
|
116
|
+
detectedProviders: () => ProviderDetectionStatus[];
|
|
117
|
+
firstBootDefaultModel: () => string | undefined;
|
|
112
118
|
deleteMessages: (personaId: string, messageIds: string[]) => Promise<void>;
|
|
113
119
|
setMessageContextStatus: (personaId: string, messageId: string, status: ContextStatus) => Promise<void>;
|
|
114
120
|
deleteRoomMessages: (roomId: string, messageIds: string[]) => Promise<void>;
|
|
@@ -146,6 +152,9 @@ export interface EiContextValue {
|
|
|
146
152
|
humanRoomMessagePending: () => boolean;
|
|
147
153
|
getArchivedRooms: () => RoomSummary[];
|
|
148
154
|
generatePersonaPreview: (name: string, description: string, relationship?: string, personaId?: string) => Promise<import('../../../src/prompts/generation/types.js').PersonaGenerationResult>;
|
|
155
|
+
importDocument: (filePath: string) => Promise<import('../../../src/integrations/document/types.js').DocumentImportResult>;
|
|
156
|
+
getUnsourcePreview: (sourceTag: string) => import('../../../src/integrations/document/unsource.js').UnsourcePreview;
|
|
157
|
+
executeUnsource: (preview: import('../../../src/integrations/document/unsource.js').UnsourcePreview) => Promise<import('../../../src/integrations/document/unsource.js').UnsourceResult>;
|
|
149
158
|
}
|
|
150
159
|
const EiContext = createContext<EiContextValue>();
|
|
151
160
|
|
|
@@ -168,6 +177,8 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
168
177
|
const [contextBoundarySignal, setContextBoundarySignal] = createSignal<string | undefined>(undefined);
|
|
169
178
|
const [quotesVersion, setQuotesVersion] = createSignal(0);
|
|
170
179
|
const [showWelcomeOverlay, setShowWelcomeOverlay] = createSignal(false);
|
|
180
|
+
const [detectedProviders, setDetectedProviders] = createSignal<ProviderDetectionStatus[]>([]);
|
|
181
|
+
const [firstBootDefaultModel, setFirstBootDefaultModel] = createSignal<string | undefined>(undefined);
|
|
171
182
|
const [conflictData, setConflictData] = createSignal<StateConflictData | null>(null);
|
|
172
183
|
|
|
173
184
|
let processor: Processor | null = null;
|
|
@@ -175,6 +186,7 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
175
186
|
let readTimer: Timer | null = null;
|
|
176
187
|
let dwelledPersona: string | null = null;
|
|
177
188
|
let syncConfiguredFromEnv = false;
|
|
189
|
+
let eiDataPath = "";
|
|
178
190
|
|
|
179
191
|
const showNotification = (message: string, level: "error" | "warn" | "info") => {
|
|
180
192
|
if (notificationTimer) clearTimeout(notificationTimer);
|
|
@@ -320,6 +332,32 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
320
332
|
return processor.generatePersonaPreview(name, description, relationship, personaId);
|
|
321
333
|
};
|
|
322
334
|
|
|
335
|
+
const importDocument = async (filePath: string) => {
|
|
336
|
+
if (!processor) throw new Error("Processor not ready");
|
|
337
|
+
const { readFile } = await import("node:fs/promises");
|
|
338
|
+
const { basename } = await import("node:path");
|
|
339
|
+
const { homedir } = await import("node:os");
|
|
340
|
+
const expandedPath = filePath === "~" || filePath.startsWith("~/")
|
|
341
|
+
? homedir() + filePath.slice(1)
|
|
342
|
+
: filePath.replace(/^\$HOME(?=\/|$)/, homedir());
|
|
343
|
+
const content = await readFile(expandedPath, "utf-8");
|
|
344
|
+
const filename = basename(expandedPath);
|
|
345
|
+
return processor.importDocument(content, filename);
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const getUnsourcePreview = (sourceTag: string) => {
|
|
349
|
+
if (!processor) throw new Error("Processor not ready");
|
|
350
|
+
return processor.getUnsourcePreview(sourceTag);
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const executeUnsource = async (preview: import('../../../src/integrations/document/unsource.js').UnsourcePreview) => {
|
|
354
|
+
if (!processor) throw new Error("Processor not ready");
|
|
355
|
+
const result = await processor.executeUnsource(preview);
|
|
356
|
+
const { writeUnsourceInvoice } = await import("../../../src/integrations/document/invoice.js");
|
|
357
|
+
await writeUnsourceInvoice(preview, result, eiDataPath);
|
|
358
|
+
return result;
|
|
359
|
+
};
|
|
360
|
+
|
|
323
361
|
const archivePersona = async (personaId: string) => {
|
|
324
362
|
if (!processor) return;
|
|
325
363
|
await processor.archivePersona(personaId);
|
|
@@ -748,64 +786,40 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
748
786
|
try {
|
|
749
787
|
const human = await processor!.getHuman();
|
|
750
788
|
const hasAccounts = human.settings?.accounts && human.settings.accounts.length > 0;
|
|
751
|
-
if (
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
id: crypto.randomUUID(),
|
|
783
|
-
name: candidate.name,
|
|
784
|
-
type: "llm" as ProviderType,
|
|
785
|
-
url: candidate.url,
|
|
786
|
-
enabled: true,
|
|
787
|
-
created_at: new Date().toISOString(),
|
|
788
|
-
default_model: defaultModelId,
|
|
789
|
-
models: [{ id: defaultModelId, name: "default" }],
|
|
790
|
-
};
|
|
791
|
-
});
|
|
792
|
-
const firstDefaultModelId = accounts[0].default_model!;
|
|
793
|
-
const currentHuman = await processor!.getHuman();
|
|
794
|
-
await processor!.updateHuman({
|
|
795
|
-
settings: {
|
|
796
|
-
...currentHuman.settings,
|
|
797
|
-
accounts,
|
|
798
|
-
default_model: firstDefaultModelId,
|
|
799
|
-
},
|
|
800
|
-
});
|
|
801
|
-
const names = found.map((c) => c.name).join(" and ");
|
|
802
|
-
showNotification(`${names} detected and configured!`, "info");
|
|
803
|
-
logger.info(`Auto-configured: ${names}`);
|
|
804
|
-
} else {
|
|
805
|
-
logger.info("No local LLMs found, showing welcome overlay");
|
|
806
|
-
setShowWelcomeOverlay(true);
|
|
807
|
-
}
|
|
789
|
+
if (hasAccounts) return;
|
|
790
|
+
|
|
791
|
+
const { detected, statuses } = await detectProviders({
|
|
792
|
+
skipLocalDetect: E2E_SKIP_LOCAL_DETECT,
|
|
793
|
+
skipCloudDetect: E2E_SKIP_CLOUD_DETECT,
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const allStatuses: ProviderDetectionStatus[] = ALL_PROVIDER_NAMES.map((name) => {
|
|
797
|
+
const found = statuses.find((s) => s.name === name);
|
|
798
|
+
return found ?? { name, detected: false };
|
|
799
|
+
});
|
|
800
|
+
setDetectedProviders(allStatuses);
|
|
801
|
+
|
|
802
|
+
if (detected.length > 0) {
|
|
803
|
+
const accounts = buildProviderAccounts(detected);
|
|
804
|
+
const topProvider = detected[0];
|
|
805
|
+
const defaultModel = `${topProvider.name}:${topProvider.selected.extractionModel}`;
|
|
806
|
+
setFirstBootDefaultModel(defaultModel);
|
|
807
|
+
const currentHuman = await processor!.getHuman();
|
|
808
|
+
await processor!.updateHuman({
|
|
809
|
+
settings: {
|
|
810
|
+
...currentHuman.settings,
|
|
811
|
+
accounts,
|
|
812
|
+
default_model: defaultModel,
|
|
813
|
+
},
|
|
814
|
+
});
|
|
815
|
+
const names = detected.map((d) => d.name).join(" and ");
|
|
816
|
+
showNotification(`${names} detected and configured!`, "info");
|
|
817
|
+
logger.info(`Auto-configured: ${names}`);
|
|
818
|
+
} else {
|
|
819
|
+
logger.info("No LLM providers found, showing welcome overlay");
|
|
808
820
|
}
|
|
821
|
+
|
|
822
|
+
setShowWelcomeOverlay(true);
|
|
809
823
|
} catch (err: any) {
|
|
810
824
|
logger.warn(`LLM detection failed: ${err?.message || err}`);
|
|
811
825
|
}
|
|
@@ -826,6 +840,7 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
826
840
|
logger.info("Ei TUI bootstrap starting");
|
|
827
841
|
try {
|
|
828
842
|
const storage = new FileStorage(Bun.env.EI_DATA_PATH);
|
|
843
|
+
eiDataPath = storage.getDataPath();
|
|
829
844
|
// Pre-configure remoteSync from env vars BEFORE processor.start()
|
|
830
845
|
// so the processor's sync decision tree can detect remote state
|
|
831
846
|
const syncUsername = Bun.env.EI_SYNC_USERNAME;
|
|
@@ -969,6 +984,8 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
969
984
|
searchHumanData,
|
|
970
985
|
showWelcomeOverlay,
|
|
971
986
|
dismissWelcomeOverlay: () => setShowWelcomeOverlay(false),
|
|
987
|
+
detectedProviders,
|
|
988
|
+
firstBootDefaultModel,
|
|
972
989
|
deleteMessages,
|
|
973
990
|
setMessageContextStatus,
|
|
974
991
|
deleteRoomMessages,
|
|
@@ -1006,6 +1023,9 @@ export const EiProvider: ParentComponent = (props) => {
|
|
|
1006
1023
|
humanRoomMessagePending,
|
|
1007
1024
|
getArchivedRooms,
|
|
1008
1025
|
generatePersonaPreview,
|
|
1026
|
+
importDocument,
|
|
1027
|
+
getUnsourcePreview,
|
|
1028
|
+
executeUnsource,
|
|
1009
1029
|
};
|
|
1010
1030
|
return (
|
|
1011
1031
|
<Switch>
|
package/tui/src/index.tsx
CHANGED
|
@@ -30,6 +30,20 @@ if (!lockResult.acquired) {
|
|
|
30
30
|
// Release lock when the app exits (keyboard context calls process.exit(0) on normal quit)
|
|
31
31
|
process.on("exit", () => { void lock.release(); });
|
|
32
32
|
|
|
33
|
+
// Validate state.json is parseable before handing off to the app.
|
|
34
|
+
// A corrupt file must never silently wipe all data — exit cleanly with recovery instructions.
|
|
35
|
+
try {
|
|
36
|
+
await storage.load();
|
|
37
|
+
} catch (e) {
|
|
38
|
+
await lock.release();
|
|
39
|
+
process.stderr.write(
|
|
40
|
+
`\nEi cannot start: state.json failed to load.\n\n` +
|
|
41
|
+
` ${e instanceof Error ? e.message : String(e)}\n\n` +
|
|
42
|
+
`Fix the file manually, restore from a backup, or delete it to start fresh (all data will be lost).\n\n`
|
|
43
|
+
);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
|
|
33
47
|
render(App, {
|
|
34
48
|
exitOnCtrlC: false,
|
|
35
49
|
targetFps: 30,
|
package/tui/src/storage/file.ts
CHANGED
|
@@ -59,15 +59,21 @@ export class FileStorage implements Storage {
|
|
|
59
59
|
async load(): Promise<StorageState | null> {
|
|
60
60
|
const filePath = join(this.dataPath, STATE_FILE);
|
|
61
61
|
const file = Bun.file(filePath);
|
|
62
|
-
|
|
62
|
+
|
|
63
63
|
if (await file.exists()) {
|
|
64
|
+
let text: string;
|
|
64
65
|
try {
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
text = await file.text();
|
|
67
|
+
} catch (e) {
|
|
68
|
+
throw new Error(`STORAGE_READ_FAILED: Could not read ${filePath}: ${e instanceof Error ? e.message : String(e)}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (text) {
|
|
72
|
+
try {
|
|
67
73
|
return decodeAllEmbeddings(JSON.parse(text) as StorageState);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
throw new Error(`STORAGE_PARSE_FAILED: ${filePath} exists but could not be parsed as JSON. Your data is intact — fix the file manually or restore from a backup in ${join(this.dataPath, "backups")}.\n Parse error: ${e instanceof Error ? e.message : String(e)}`);
|
|
68
76
|
}
|
|
69
|
-
} catch {
|
|
70
|
-
return null;
|
|
71
77
|
}
|
|
72
78
|
}
|
|
73
79
|
|
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Use prime-power bits so combinations are unambiguous:
|
|
5
5
|
* 1 — skip local LLM auto-detect (fetch to :1234/:11434)
|
|
6
|
-
* 2 — (
|
|
7
|
-
* 3 — flags 1 + 2 combined
|
|
6
|
+
* 2 — skip cloud provider auto-detect (env var → /models checks)
|
|
7
|
+
* 3 — flags 1 + 2 combined (skip all auto-detect)
|
|
8
8
|
*
|
|
9
9
|
* Production code should never set this. Tests pass it via env in test.use({ env: { EI_E2E_MODE: "1" } }).
|
|
10
10
|
*/
|
|
11
11
|
const E2E_MODE = parseInt(process.env.EI_E2E_MODE ?? "0", 10);
|
|
12
12
|
|
|
13
|
-
export const E2E_SKIP_LOCAL_DETECT
|
|
13
|
+
export const E2E_SKIP_LOCAL_DETECT = (E2E_MODE & 1) !== 0;
|
|
14
|
+
export const E2E_SKIP_CLOUD_DETECT = (E2E_MODE & 2) !== 0;
|
|
@@ -89,6 +89,26 @@ ROOM COMMANDS
|
|
|
89
89
|
/capture topic <name> Re-scan all messages for a specific topic.
|
|
90
90
|
|
|
91
91
|
EXTENDED COMMANDS
|
|
92
|
+
/reflect
|
|
93
|
+
Review a persona's pending reflection — a proposed identity update
|
|
94
|
+
generated by Ei after observing patterns in your conversations.
|
|
95
|
+
A badge appears on the persona pill when one is ready.
|
|
96
|
+
/reflect generate Write current + proposed YAML files to disk
|
|
97
|
+
/reflect update Read edited proposed.yaml back into Ei
|
|
98
|
+
/reflect apply Apply the proposed identity to the persona
|
|
99
|
+
/reflect dismiss Discard without changing anything
|
|
100
|
+
|
|
101
|
+
/import <path>
|
|
102
|
+
Import a document (txt, md, pdf, etc.) into Ei. Ei segments it,
|
|
103
|
+
extracts knowledge, and attributes it to the "Emmett" persona.
|
|
104
|
+
/import ~/notes/journal.md
|
|
105
|
+
/import /path/to/report.pdf
|
|
106
|
+
|
|
107
|
+
/unsource <source_tag>
|
|
108
|
+
Remove all facts, topics, etc. extracted from a previously imported
|
|
109
|
+
document. Use the source tag shown when the import completed.
|
|
110
|
+
/unsource my-journal-2024
|
|
111
|
+
|
|
92
112
|
/tools
|
|
93
113
|
Manage tool providers — enable or disable tools per persona.
|
|
94
114
|
|
package/tui/src/util/logger.ts
CHANGED
|
@@ -22,7 +22,7 @@ const LOG_LEVELS: Record<LogLevel, number> = {
|
|
|
22
22
|
error: 3,
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
-
const currentLevel: LogLevel = (Bun.env.EI_LOG_LEVEL as LogLevel) || "
|
|
25
|
+
const currentLevel: LogLevel = (Bun.env.EI_LOG_LEVEL as LogLevel) || "warn";
|
|
26
26
|
|
|
27
27
|
function shouldLog(level: LogLevel): boolean {
|
|
28
28
|
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import type { ProviderType } from "../../../src/core/types.js";
|
|
2
|
+
import type { ProviderAccount, ModelConfig } from "../../../src/core/types.js";
|
|
3
|
+
|
|
4
|
+
export interface LocalProviderConfig {
|
|
5
|
+
name: string;
|
|
6
|
+
url: string;
|
|
7
|
+
priority: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface CloudProviderConfig {
|
|
11
|
+
name: string;
|
|
12
|
+
envVar: string;
|
|
13
|
+
url: string;
|
|
14
|
+
priority: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SelectedModels {
|
|
18
|
+
extractionModel: string;
|
|
19
|
+
chatModel: string;
|
|
20
|
+
bonusModel?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ProviderDetectionResult {
|
|
24
|
+
name: string;
|
|
25
|
+
url: string;
|
|
26
|
+
apiKey?: string;
|
|
27
|
+
modelIds: string[];
|
|
28
|
+
selected: SelectedModels;
|
|
29
|
+
status: "detected" | "failed";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ProviderDetectionStatus {
|
|
33
|
+
name: string;
|
|
34
|
+
detected: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface DetectProvidersOptions {
|
|
38
|
+
skipLocalDetect?: boolean;
|
|
39
|
+
skipCloudDetect?: boolean;
|
|
40
|
+
env?: Record<string, string | undefined>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const LOCAL_PROVIDERS: ReadonlyArray<LocalProviderConfig> = [
|
|
44
|
+
{ name: "LMStudio", url: "http://127.0.0.1:1234/v1", priority: 1 },
|
|
45
|
+
{ name: "Ollama", url: "http://127.0.0.1:11434/v1", priority: 2 },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
export const CLOUD_PROVIDERS: ReadonlyArray<CloudProviderConfig> = [
|
|
49
|
+
{ name: "Anthropic", envVar: "ANTHROPIC_API_KEY", url: "https://api.anthropic.com/v1", priority: 3 },
|
|
50
|
+
{ name: "OpenAI", envVar: "OPENAI_API_KEY", url: "https://api.openai.com/v1", priority: 4 },
|
|
51
|
+
{ name: "Groq", envVar: "GROQ_API_KEY", url: "https://api.groq.com/openai/v1", priority: 5 },
|
|
52
|
+
{ name: "Mistral", envVar: "MISTRAL_API_KEY", url: "https://api.mistral.ai/v1", priority: 6 },
|
|
53
|
+
{ name: "Gemini", envVar: "GEMINI_API_KEY", url: "https://generativelanguage.googleapis.com/v1beta/openai", priority: 7 },
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
export const ALL_PROVIDER_NAMES: ReadonlyArray<string> = [
|
|
57
|
+
...LOCAL_PROVIDERS.map((p) => p.name),
|
|
58
|
+
...CLOUD_PROVIDERS.map((p) => p.name),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function latestMatch(modelIds: string[], pattern: string): string | undefined {
|
|
62
|
+
const matches = modelIds.filter((id) => id.toLowerCase().includes(pattern));
|
|
63
|
+
if (matches.length === 0) return undefined;
|
|
64
|
+
return [...matches].sort().reverse()[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function selectModelsForProvider(
|
|
68
|
+
providerName: string,
|
|
69
|
+
modelIds: string[]
|
|
70
|
+
): SelectedModels {
|
|
71
|
+
const name = providerName.toLowerCase();
|
|
72
|
+
|
|
73
|
+
if (name === "groq") {
|
|
74
|
+
return {
|
|
75
|
+
extractionModel: "llama-3.1-8b-instant",
|
|
76
|
+
chatModel: "llama-3.3-70b-versatile",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (modelIds.length === 0) {
|
|
81
|
+
return { extractionModel: "default", chatModel: "default" };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (name === "anthropic") {
|
|
85
|
+
return {
|
|
86
|
+
extractionModel: latestMatch(modelIds, "haiku") ?? modelIds[0],
|
|
87
|
+
chatModel: latestMatch(modelIds, "sonnet") ?? modelIds[0],
|
|
88
|
+
bonusModel: latestMatch(modelIds, "opus"),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (name === "openai") {
|
|
93
|
+
const gpt4oNonMini = modelIds.filter(
|
|
94
|
+
(id) => id.toLowerCase().includes("gpt-4o") && !id.toLowerCase().includes("mini")
|
|
95
|
+
);
|
|
96
|
+
return {
|
|
97
|
+
extractionModel: latestMatch(modelIds, "mini") ?? modelIds[0],
|
|
98
|
+
chatModel: gpt4oNonMini.length > 0
|
|
99
|
+
? [...gpt4oNonMini].sort().reverse()[0]
|
|
100
|
+
: modelIds[0],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (name === "mistral") {
|
|
105
|
+
return {
|
|
106
|
+
extractionModel: latestMatch(modelIds, "small") ?? modelIds[0],
|
|
107
|
+
chatModel: latestMatch(modelIds, "large") ?? modelIds[0],
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (name === "gemini") {
|
|
112
|
+
return {
|
|
113
|
+
extractionModel: latestMatch(modelIds, "flash") ?? modelIds[0],
|
|
114
|
+
chatModel: latestMatch(modelIds, "pro") ?? modelIds[0],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return { extractionModel: modelIds[0], chatModel: modelIds[0] };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type FetchFn = (url: string, init?: RequestInit) => Promise<Response>;
|
|
122
|
+
|
|
123
|
+
function buildAuthHeaders(url: string, apiKey: string | undefined): Record<string, string> {
|
|
124
|
+
if (!apiKey) return {};
|
|
125
|
+
if (url.includes("api.anthropic.com")) {
|
|
126
|
+
return {
|
|
127
|
+
"x-api-key": apiKey,
|
|
128
|
+
"anthropic-version": "2023-06-01",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return { "Authorization": `Bearer ${apiKey}` };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function probeModels(
|
|
135
|
+
url: string,
|
|
136
|
+
apiKey: string | undefined,
|
|
137
|
+
fetchFn: FetchFn
|
|
138
|
+
): Promise<string[] | null> {
|
|
139
|
+
try {
|
|
140
|
+
const headers = buildAuthHeaders(url, apiKey);
|
|
141
|
+
const response = await fetchFn(`${url}/models`, {
|
|
142
|
+
method: "GET",
|
|
143
|
+
headers,
|
|
144
|
+
signal: AbortSignal.timeout(3000),
|
|
145
|
+
});
|
|
146
|
+
if (!response.ok) return null;
|
|
147
|
+
const json = await response.json() as { data?: Array<{ id: string }> };
|
|
148
|
+
return (json.data ?? []).map((m) => m.id).filter(Boolean);
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export async function detectProviders(
|
|
155
|
+
options: DetectProvidersOptions = {},
|
|
156
|
+
fetchFn: FetchFn = fetch
|
|
157
|
+
): Promise<{
|
|
158
|
+
detected: ProviderDetectionResult[];
|
|
159
|
+
statuses: ProviderDetectionStatus[];
|
|
160
|
+
}> {
|
|
161
|
+
const env = options.env ?? (process.env as Record<string, string | undefined>);
|
|
162
|
+
const detected: ProviderDetectionResult[] = [];
|
|
163
|
+
const statuses: ProviderDetectionStatus[] = [];
|
|
164
|
+
|
|
165
|
+
const localResults = await Promise.all(
|
|
166
|
+
LOCAL_PROVIDERS.map(async (provider) => {
|
|
167
|
+
if (options.skipLocalDetect) return { provider, modelIds: null };
|
|
168
|
+
const modelIds = await probeModels(provider.url, undefined, fetchFn);
|
|
169
|
+
return { provider, modelIds };
|
|
170
|
+
})
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
for (const { provider, modelIds } of localResults) {
|
|
174
|
+
const ok = modelIds !== null;
|
|
175
|
+
statuses.push({ name: provider.name, detected: ok });
|
|
176
|
+
if (ok) {
|
|
177
|
+
detected.push({
|
|
178
|
+
name: provider.name,
|
|
179
|
+
url: provider.url,
|
|
180
|
+
modelIds: modelIds!,
|
|
181
|
+
selected: selectModelsForProvider(provider.name, modelIds!),
|
|
182
|
+
status: "detected",
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const cloudResults = await Promise.all(
|
|
188
|
+
CLOUD_PROVIDERS.map(async (provider) => {
|
|
189
|
+
const apiKey = env[provider.envVar];
|
|
190
|
+
if (!apiKey) return { provider, apiKey: undefined, modelIds: null };
|
|
191
|
+
if (options.skipCloudDetect) return { provider, apiKey, modelIds: null };
|
|
192
|
+
const modelIds = await probeModels(provider.url, apiKey, fetchFn);
|
|
193
|
+
return { provider, apiKey, modelIds };
|
|
194
|
+
})
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
for (const { provider, apiKey, modelIds } of cloudResults) {
|
|
198
|
+
const ok = modelIds !== null;
|
|
199
|
+
statuses.push({ name: provider.name, detected: ok });
|
|
200
|
+
if (ok) {
|
|
201
|
+
detected.push({
|
|
202
|
+
name: provider.name,
|
|
203
|
+
url: provider.url,
|
|
204
|
+
apiKey,
|
|
205
|
+
modelIds: modelIds!,
|
|
206
|
+
selected: selectModelsForProvider(provider.name, modelIds!),
|
|
207
|
+
status: "detected",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return { detected, statuses };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function buildProviderAccounts(
|
|
216
|
+
detected: ProviderDetectionResult[]
|
|
217
|
+
): ProviderAccount[] {
|
|
218
|
+
return detected.map((d) => {
|
|
219
|
+
const makeModel = (modelName: string): ModelConfig => ({
|
|
220
|
+
id: crypto.randomUUID(),
|
|
221
|
+
name: modelName,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const seenNames = new Set<string>();
|
|
225
|
+
const models: ModelConfig[] = [];
|
|
226
|
+
|
|
227
|
+
const pushIfNew = (name: string) => {
|
|
228
|
+
if (!seenNames.has(name)) {
|
|
229
|
+
seenNames.add(name);
|
|
230
|
+
models.push(makeModel(name));
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
pushIfNew(d.selected.chatModel);
|
|
235
|
+
pushIfNew(d.selected.extractionModel);
|
|
236
|
+
if (d.selected.bonusModel) pushIfNew(d.selected.bonusModel);
|
|
237
|
+
for (const id of d.modelIds) pushIfNew(id);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
id: crypto.randomUUID(),
|
|
241
|
+
name: d.name,
|
|
242
|
+
type: "llm" as ProviderType,
|
|
243
|
+
url: d.url,
|
|
244
|
+
api_key: d.apiKey,
|
|
245
|
+
enabled: true,
|
|
246
|
+
created_at: new Date().toISOString(),
|
|
247
|
+
default_model: d.selected.chatModel,
|
|
248
|
+
models,
|
|
249
|
+
};
|
|
250
|
+
});
|
|
251
|
+
}
|
|
@@ -191,6 +191,12 @@ export function humanToYAML(
|
|
|
191
191
|
})
|
|
192
192
|
.replace(/^(\s+)(identifiers:)/mg, (_, indent, _key) => {
|
|
193
193
|
return `${indent}${personComment}\n${indent}identifiers:`;
|
|
194
|
+
})
|
|
195
|
+
.replace(/^( +)(sources:\n(?:\1 - .+\n)*)/mg, (match, indent, block) => {
|
|
196
|
+
return block
|
|
197
|
+
.split('\n')
|
|
198
|
+
.map(line => line.trim() ? `${indent}# [read-only] ${line}` : line)
|
|
199
|
+
.join('\n');
|
|
194
200
|
});
|
|
195
201
|
|
|
196
202
|
const serializeSection = (key: "facts" | "topics" | "people", items: unknown[] | undefined): string => {
|
|
@@ -199,7 +205,7 @@ export function humanToYAML(
|
|
|
199
205
|
return `${key}:\n${stub}`;
|
|
200
206
|
}
|
|
201
207
|
const ordered = (items as object[]).map(canonicalFieldOrder);
|
|
202
|
-
const itemsYaml = YAML.stringify(ordered, { lineWidth: 0 })
|
|
208
|
+
const itemsYaml = YAML.stringify(ordered, { lineWidth: 0, aliasDuplicateObjects: false })
|
|
203
209
|
.split('\n')
|
|
204
210
|
.map(line => ` ${line}`)
|
|
205
211
|
.join('\n')
|