Add BRAIN_DISABLED flag and fix Ollama tool call formatting
Features: - Add BRAIN_DISABLED feature flag to hide all Brain functionality - When enabled, hides Brain banner, status indicator, menu, and commands - Flag location: src/constants/brain.ts Fixes: - Fix Ollama 400 error by properly formatting tool_calls in messages - Update OllamaMessage type to include tool_calls field - Fix Brain menu keyboard not working (add missing modes to isMenuOpen) UI Changes: - Remove "^Tab toggle mode" hint from status bar - Remove "ctrl+t to hide todos" hint from status bar Files modified: - src/constants/brain.ts (add BRAIN_DISABLED flag) - src/types/ollama.ts (add tool_calls to OllamaMessage) - src/providers/ollama/chat.ts (format tool_calls in messages) - src/tui-solid/components/header.tsx (hide Brain UI when disabled) - src/tui-solid/components/status-bar.tsx (remove hints) - src/tui-solid/components/command-menu.tsx (filter brain command) - src/tui-solid/components/input-area.tsx (fix isMenuOpen modes) - src/tui-solid/routes/session.tsx (skip brain menu when disabled) - src/services/brain.ts (early return when disabled) - src/services/chat-tui/initialize.ts (skip brain init when disabled)
This commit is contained in:
688
src/services/brain.ts
Normal file
688
src/services/brain.ts
Normal file
@@ -0,0 +1,688 @@
|
||||
/**
|
||||
* Brain Service
|
||||
*
|
||||
* Business logic layer for the CodeTyper Brain integration.
|
||||
* Provides context injection, knowledge recall, and learning capabilities.
|
||||
*/
|
||||
|
||||
import fs from "fs/promises";
|
||||
import { DIRS, FILES } from "@constants/paths";
|
||||
import { BRAIN_DEFAULTS, BRAIN_ERRORS, BRAIN_DISABLED } from "@constants/brain";
|
||||
import * as brainApi from "@api/brain";
|
||||
import type {
|
||||
BrainCredentials,
|
||||
BrainState,
|
||||
BrainConnectionStatus,
|
||||
BrainUser,
|
||||
BrainConcept,
|
||||
BrainRecallResponse,
|
||||
BrainExtractResponse,
|
||||
} from "@/types/brain";
|
||||
|
||||
// ============================================================================
|
||||
// State Management (Singleton via Closure)
|
||||
// ============================================================================
|
||||
|
||||
interface VarsFile {
|
||||
brainApiKey?: string;
|
||||
brainJwtToken?: string;
|
||||
}
|
||||
|
||||
let brainState: BrainState = {
|
||||
status: "disconnected",
|
||||
user: null,
|
||||
projectId: BRAIN_DEFAULTS.PROJECT_ID,
|
||||
knowledgeCount: 0,
|
||||
memoryCount: 0,
|
||||
lastError: null,
|
||||
};
|
||||
|
||||
let cachedCredentials: BrainCredentials | null = null;
|
||||
let cachedVars: VarsFile | null = null;
|
||||
|
||||
// ============================================================================
|
||||
// Vars File Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Load vars file from disk
|
||||
*/
|
||||
const loadVarsFile = async (): Promise<VarsFile> => {
|
||||
if (cachedVars) {
|
||||
return cachedVars;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(FILES.vars, "utf-8");
|
||||
cachedVars = JSON.parse(data) as VarsFile;
|
||||
return cachedVars;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save vars file to disk
|
||||
*/
|
||||
const saveVarsFile = async (vars: VarsFile): Promise<void> => {
|
||||
try {
|
||||
await fs.mkdir(DIRS.config, { recursive: true });
|
||||
await fs.writeFile(FILES.vars, JSON.stringify(vars, null, 2), "utf-8");
|
||||
cachedVars = vars;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save vars file: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Credentials Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get path to brain credentials file
|
||||
*/
|
||||
const getCredentialsPath = (): string => {
|
||||
return `${DIRS.data}/brain-credentials.json`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load brain credentials from disk
|
||||
*/
|
||||
export const loadCredentials = async (): Promise<BrainCredentials | null> => {
|
||||
if (cachedCredentials) {
|
||||
return cachedCredentials;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await fs.readFile(getCredentialsPath(), "utf-8");
|
||||
cachedCredentials = JSON.parse(data) as BrainCredentials;
|
||||
return cachedCredentials;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Save brain credentials to disk
|
||||
*/
|
||||
export const saveCredentials = async (
|
||||
credentials: BrainCredentials,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await fs.mkdir(DIRS.data, { recursive: true });
|
||||
await fs.writeFile(
|
||||
getCredentialsPath(),
|
||||
JSON.stringify(credentials, null, 2),
|
||||
"utf-8",
|
||||
);
|
||||
cachedCredentials = credentials;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to save brain credentials: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear brain credentials
|
||||
*/
|
||||
export const clearCredentials = async (): Promise<void> => {
|
||||
try {
|
||||
await fs.unlink(getCredentialsPath());
|
||||
cachedCredentials = null;
|
||||
} catch {
|
||||
// File may not exist, ignore
|
||||
}
|
||||
|
||||
// Also clear vars file entries
|
||||
try {
|
||||
const vars = await loadVarsFile();
|
||||
await saveVarsFile({
|
||||
...vars,
|
||||
brainApiKey: undefined,
|
||||
brainJwtToken: undefined,
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get API key from vars file or environment
|
||||
*/
|
||||
export const getApiKey = async (): Promise<string | undefined> => {
|
||||
// First check environment variable
|
||||
const envKey = process.env.CODETYPER_BRAIN_API_KEY;
|
||||
if (envKey) {
|
||||
return envKey;
|
||||
}
|
||||
|
||||
// Then check vars file
|
||||
const vars = await loadVarsFile();
|
||||
return vars.brainApiKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get JWT token from vars file
|
||||
*/
|
||||
export const getJwtToken = async (): Promise<string | undefined> => {
|
||||
const vars = await loadVarsFile();
|
||||
return vars.brainJwtToken;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set API key in vars file
|
||||
*/
|
||||
export const setApiKey = async (apiKey: string): Promise<void> => {
|
||||
const vars = await loadVarsFile();
|
||||
await saveVarsFile({ ...vars, brainApiKey: apiKey });
|
||||
};
|
||||
|
||||
/**
|
||||
* Set JWT token in vars file
|
||||
*/
|
||||
export const setJwtToken = async (jwtToken: string): Promise<void> => {
|
||||
const vars = await loadVarsFile();
|
||||
await saveVarsFile({ ...vars, brainJwtToken: jwtToken });
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Authentication
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Login to Brain service
|
||||
*/
|
||||
export const login = async (
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ success: boolean; user?: BrainUser; error?: string }> => {
|
||||
try {
|
||||
updateState({ status: "connecting" });
|
||||
|
||||
const response = await brainApi.login(email, password);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const credentials: BrainCredentials = {
|
||||
accessToken: response.data.access_token,
|
||||
refreshToken: response.data.refresh_token,
|
||||
expiresAt: response.data.expires_at,
|
||||
user: response.data.user,
|
||||
};
|
||||
|
||||
await saveCredentials(credentials);
|
||||
|
||||
updateState({
|
||||
status: "connected",
|
||||
user: response.data.user,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
return { success: true, user: response.data.user };
|
||||
}
|
||||
|
||||
updateState({ status: "error", lastError: "Login failed" });
|
||||
return { success: false, error: "Login failed" };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
updateState({ status: "error", lastError: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register a new account
|
||||
*/
|
||||
export const register = async (
|
||||
email: string,
|
||||
password: string,
|
||||
displayName: string,
|
||||
): Promise<{ success: boolean; user?: BrainUser; error?: string }> => {
|
||||
try {
|
||||
updateState({ status: "connecting" });
|
||||
|
||||
const response = await brainApi.register(email, password, displayName);
|
||||
|
||||
if (response.success && response.data) {
|
||||
const credentials: BrainCredentials = {
|
||||
accessToken: response.data.access_token,
|
||||
refreshToken: response.data.refresh_token,
|
||||
expiresAt: response.data.expires_at,
|
||||
user: response.data.user,
|
||||
};
|
||||
|
||||
await saveCredentials(credentials);
|
||||
|
||||
updateState({
|
||||
status: "connected",
|
||||
user: response.data.user,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
return { success: true, user: response.data.user };
|
||||
}
|
||||
|
||||
updateState({ status: "error", lastError: "Registration failed" });
|
||||
return { success: false, error: "Registration failed" };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
updateState({ status: "error", lastError: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Logout from Brain service
|
||||
*/
|
||||
export const logout = async (): Promise<void> => {
|
||||
try {
|
||||
const credentials = await loadCredentials();
|
||||
if (credentials?.refreshToken) {
|
||||
await brainApi.logout(credentials.refreshToken);
|
||||
}
|
||||
} catch {
|
||||
// Ignore logout errors
|
||||
} finally {
|
||||
await clearCredentials();
|
||||
updateState({
|
||||
status: "disconnected",
|
||||
user: null,
|
||||
knowledgeCount: 0,
|
||||
memoryCount: 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Connection Management
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get authentication token (API key or JWT token)
|
||||
*/
|
||||
export const getAuthToken = async (): Promise<string | undefined> => {
|
||||
const apiKey = await getApiKey();
|
||||
if (apiKey) {
|
||||
return apiKey;
|
||||
}
|
||||
return getJwtToken();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if Brain service is available and connect
|
||||
*/
|
||||
export const connect = async (): Promise<boolean> => {
|
||||
// Skip connection when Brain is disabled
|
||||
if (BRAIN_DISABLED) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
updateState({ status: "connecting" });
|
||||
|
||||
// First check if service is healthy
|
||||
await brainApi.checkHealth();
|
||||
|
||||
// Then check if we have valid credentials (API key or JWT token)
|
||||
const authToken = await getAuthToken();
|
||||
if (!authToken) {
|
||||
updateState({ status: "disconnected", lastError: null });
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try to get stats to verify credentials are valid
|
||||
const projectId = brainState.projectId ?? BRAIN_DEFAULTS.PROJECT_ID;
|
||||
const statsResponse = await brainApi.getKnowledgeStats(projectId, authToken);
|
||||
|
||||
if (statsResponse.success && statsResponse.data) {
|
||||
updateState({
|
||||
status: "connected",
|
||||
knowledgeCount: statsResponse.data.total_concepts,
|
||||
lastError: null,
|
||||
});
|
||||
|
||||
// Also try to get memory stats
|
||||
try {
|
||||
const memoryStats = await brainApi.getMemoryStats(authToken);
|
||||
updateState({ memoryCount: memoryStats.totalNodes });
|
||||
} catch {
|
||||
// Memory stats are optional
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
updateState({ status: "error", lastError: BRAIN_ERRORS.INVALID_API_KEY });
|
||||
return false;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : BRAIN_ERRORS.CONNECTION_FAILED;
|
||||
updateState({ status: "error", lastError: errorMessage });
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Disconnect from Brain service
|
||||
*/
|
||||
export const disconnect = (): void => {
|
||||
updateState({
|
||||
status: "disconnected",
|
||||
knowledgeCount: 0,
|
||||
memoryCount: 0,
|
||||
lastError: null,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if connected to Brain
|
||||
*/
|
||||
export const isConnected = (): boolean => {
|
||||
if (BRAIN_DISABLED) return false;
|
||||
return brainState.status === "connected";
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Knowledge Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Recall relevant knowledge for a query
|
||||
*/
|
||||
export const recall = async (
|
||||
query: string,
|
||||
limit = 5,
|
||||
): Promise<BrainRecallResponse | null> => {
|
||||
if (!isConnected()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await brainApi.recallKnowledge(
|
||||
{
|
||||
query,
|
||||
project_id: brainState.projectId ?? BRAIN_DEFAULTS.PROJECT_ID,
|
||||
limit,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : BRAIN_ERRORS.RECALL_FAILED;
|
||||
updateState({ lastError: errorMessage });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get context string for prompt injection
|
||||
*/
|
||||
export const getContext = async (
|
||||
query: string,
|
||||
maxConcepts = 3,
|
||||
): Promise<string | null> => {
|
||||
if (!isConnected()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await brainApi.buildContext(
|
||||
{
|
||||
query,
|
||||
project_id: brainState.projectId ?? BRAIN_DEFAULTS.PROJECT_ID,
|
||||
max_concepts: maxConcepts,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
|
||||
if (response.success && response.data.has_knowledge) {
|
||||
return response.data.context;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Learn a concept
|
||||
*/
|
||||
export const learn = async (
|
||||
name: string,
|
||||
whatItDoes: string,
|
||||
options?: {
|
||||
howItWorks?: string;
|
||||
patterns?: string[];
|
||||
files?: string[];
|
||||
keyFunctions?: string[];
|
||||
aliases?: string[];
|
||||
},
|
||||
): Promise<BrainConcept | null> => {
|
||||
if (!isConnected()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await brainApi.learnConcept(
|
||||
{
|
||||
project_id: brainState.projectId ?? BRAIN_DEFAULTS.PROJECT_ID,
|
||||
name,
|
||||
what_it_does: whatItDoes,
|
||||
how_it_works: options?.howItWorks,
|
||||
patterns: options?.patterns,
|
||||
files: options?.files,
|
||||
key_functions: options?.keyFunctions,
|
||||
aliases: options?.aliases,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
|
||||
if (response.success && response.data) {
|
||||
// Update knowledge count
|
||||
updateState({ knowledgeCount: brainState.knowledgeCount + 1 });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : BRAIN_ERRORS.LEARN_FAILED;
|
||||
updateState({ lastError: errorMessage });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract and learn concepts from content
|
||||
*/
|
||||
export const extractAndLearn = async (
|
||||
content: string,
|
||||
source = "conversation",
|
||||
): Promise<BrainExtractResponse | null> => {
|
||||
if (!isConnected()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await brainApi.extractConcepts(
|
||||
{
|
||||
content,
|
||||
project_id: brainState.projectId ?? BRAIN_DEFAULTS.PROJECT_ID,
|
||||
source,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
// Update knowledge count
|
||||
const newCount =
|
||||
brainState.knowledgeCount + response.data.stored + response.data.updated;
|
||||
updateState({ knowledgeCount: newCount });
|
||||
return response;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : BRAIN_ERRORS.EXTRACT_FAILED;
|
||||
updateState({ lastError: errorMessage });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Memory Operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Search memories
|
||||
*/
|
||||
export const searchMemories = async (
|
||||
query: string,
|
||||
limit = 10,
|
||||
): Promise<{ memories: Array<{ content: string; similarity: number }> } | null> => {
|
||||
if (!isConnected()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await getApiKey();
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await brainApi.searchMemories(
|
||||
{
|
||||
query,
|
||||
limit,
|
||||
project_id: brainState.projectId ?? BRAIN_DEFAULTS.PROJECT_ID,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return {
|
||||
memories: response.memories.map((m) => ({
|
||||
content: m.content,
|
||||
similarity: m.similarity ?? 0,
|
||||
})),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Store a memory
|
||||
*/
|
||||
export const storeMemory = async (
|
||||
content: string,
|
||||
type: "fact" | "pattern" | "correction" | "preference" | "context" = "context",
|
||||
): Promise<boolean> => {
|
||||
if (!isConnected()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiKey = await getApiKey();
|
||||
if (!apiKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await brainApi.storeMemory(
|
||||
{
|
||||
content,
|
||||
type,
|
||||
project_id: brainState.projectId ?? BRAIN_DEFAULTS.PROJECT_ID,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
updateState({ memoryCount: brainState.memoryCount + 1 });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// State Accessors
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get current brain state
|
||||
*/
|
||||
export const getState = (): BrainState => {
|
||||
return { ...brainState };
|
||||
};
|
||||
|
||||
/**
|
||||
* Update brain state
|
||||
*/
|
||||
const updateState = (updates: Partial<BrainState>): void => {
|
||||
brainState = { ...brainState, ...updates };
|
||||
};
|
||||
|
||||
/**
|
||||
* Set project ID
|
||||
*/
|
||||
export const setProjectId = (projectId: number): void => {
|
||||
updateState({ projectId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get connection status
|
||||
*/
|
||||
export const getStatus = (): BrainConnectionStatus => {
|
||||
return brainState.status;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if authenticated (has API key or JWT token)
|
||||
*/
|
||||
export const isAuthenticated = async (): Promise<boolean> => {
|
||||
const apiKey = await getApiKey();
|
||||
const jwtToken = await getJwtToken();
|
||||
return apiKey !== undefined || jwtToken !== undefined;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Initialization
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Initialize brain service (auto-connect if credentials available)
|
||||
*/
|
||||
export const initialize = async (): Promise<boolean> => {
|
||||
const hasAuth = await isAuthenticated();
|
||||
if (hasAuth) {
|
||||
return connect();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
Reference in New Issue
Block a user