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:
2026-02-02 13:25:38 -05:00
parent 2eadda584a
commit c839fc4d68
114 changed files with 17243 additions and 273 deletions

View File

@@ -0,0 +1,523 @@
/**
* Cloud Sync Service
*
* Handles push/pull synchronization with the cloud brain service.
*/
import {
CLOUD_BRAIN_DEFAULTS,
CLOUD_ENDPOINTS,
CLOUD_ERRORS,
CLOUD_MESSAGES,
CLOUD_HTTP_CONFIG,
SYNC_CONFIG,
} from "@constants/brain-cloud";
import {
enqueue,
enqueueBatch,
dequeue,
markProcessed,
markFailed,
hasQueuedItems,
getQueueSize,
clearQueue,
} from "@services/brain/offline-queue";
import {
createConflict,
resolveAllConflicts,
getPendingConflicts,
hasUnresolvedConflicts,
clearResolvedConflicts,
} from "@services/brain/conflict-resolver";
import type {
BrainSyncState,
CloudBrainConfig,
SyncItem,
SyncResult,
SyncOptions,
PushRequest,
PushResponse,
PullRequest,
PullResponse,
} from "@/types/brain-cloud";
// Sync state
let syncState: BrainSyncState = {
status: "synced",
lastSyncAt: null,
lastPushAt: null,
lastPullAt: null,
pendingChanges: 0,
conflictCount: 0,
syncErrors: [],
};
// Cloud configuration
let cloudConfig: CloudBrainConfig = { ...CLOUD_BRAIN_DEFAULTS };
// Sync lock to prevent concurrent syncs
let syncInProgress = false;
// Local version tracking
let localVersion = 0;
/**
* Configure cloud sync
*/
export const configure = (config: Partial<CloudBrainConfig>): void => {
cloudConfig = { ...cloudConfig, ...config };
};
/**
* Get current sync state
*/
export const getSyncState = (): BrainSyncState => ({ ...syncState });
/**
* Get cloud configuration
*/
export const getConfig = (): CloudBrainConfig => ({ ...cloudConfig });
/**
* Check if cloud sync is enabled
*/
export const isEnabled = (): boolean => cloudConfig.enabled;
/**
* Check if device is online
*/
const isOnline = (): boolean => {
// In Node.js/Bun, we'll assume online unless proven otherwise
return true;
};
/**
* Perform a full sync (push then pull)
*/
export const sync = async (
authToken: string,
projectId: number,
options: SyncOptions = {},
): Promise<SyncResult> => {
if (!cloudConfig.enabled) {
throw new Error(CLOUD_ERRORS.NOT_CONFIGURED);
}
if (syncInProgress) {
throw new Error(CLOUD_ERRORS.SYNC_IN_PROGRESS);
}
if (!isOnline()) {
syncState.status = "offline";
throw new Error(CLOUD_ERRORS.OFFLINE);
}
syncInProgress = true;
syncState.status = "syncing";
syncState.syncErrors = [];
const startTime = Date.now();
const result: SyncResult = {
success: true,
direction: options.direction ?? "both",
itemsSynced: 0,
itemsFailed: 0,
conflicts: [],
errors: [],
duration: 0,
timestamp: startTime,
};
try {
const direction = options.direction ?? "both";
// Push local changes
if (direction === "push" || direction === "both") {
options.onProgress?.({
phase: "pushing",
current: 0,
total: await getQueueSize(),
message: CLOUD_MESSAGES.STARTING_SYNC,
});
const pushResult = await pushChanges(authToken, projectId, options);
result.itemsSynced += pushResult.itemsSynced;
result.itemsFailed += pushResult.itemsFailed;
result.conflicts.push(...pushResult.conflicts);
result.errors.push(...pushResult.errors);
if (pushResult.errors.length > 0) {
result.success = false;
}
}
// Pull remote changes
if (direction === "pull" || direction === "both") {
options.onProgress?.({
phase: "pulling",
current: 0,
total: 0,
message: CLOUD_MESSAGES.PULLING(0),
});
const pullResult = await pullChanges(authToken, projectId, options);
result.itemsSynced += pullResult.itemsSynced;
result.itemsFailed += pullResult.itemsFailed;
result.conflicts.push(...pullResult.conflicts);
result.errors.push(...pullResult.errors);
if (pullResult.errors.length > 0) {
result.success = false;
}
}
// Handle conflicts if any
if (result.conflicts.length > 0) {
options.onProgress?.({
phase: "resolving",
current: 0,
total: result.conflicts.length,
message: CLOUD_MESSAGES.RESOLVING_CONFLICTS(result.conflicts.length),
});
const strategy = options.conflictStrategy ?? cloudConfig.conflictStrategy;
if (strategy !== "manual") {
resolveAllConflicts(strategy);
result.conflicts = getPendingConflicts();
}
if (hasUnresolvedConflicts()) {
syncState.status = "conflict";
syncState.conflictCount = result.conflicts.length;
}
}
// Update state
result.duration = Date.now() - startTime;
if (result.success && result.conflicts.length === 0) {
syncState.status = "synced";
syncState.lastSyncAt = Date.now();
} else if (result.conflicts.length > 0) {
syncState.status = "conflict";
} else {
syncState.status = "error";
}
syncState.pendingChanges = await getQueueSize();
syncState.syncErrors = result.errors;
options.onProgress?.({
phase: "completing",
current: result.itemsSynced,
total: result.itemsSynced,
message: CLOUD_MESSAGES.SYNC_COMPLETE,
});
return result;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
syncState.status = "error";
syncState.syncErrors.push(message);
result.success = false;
result.errors.push(message);
result.duration = Date.now() - startTime;
return result;
} finally {
syncInProgress = false;
clearResolvedConflicts();
}
};
/**
* Push local changes to cloud
*/
const pushChanges = async (
authToken: string,
projectId: number,
options: SyncOptions,
): Promise<Omit<SyncResult, "direction" | "timestamp">> => {
const result = {
success: true,
itemsSynced: 0,
itemsFailed: 0,
conflicts: [] as SyncResult["conflicts"],
errors: [] as string[],
duration: 0,
};
// Get queued items
const queuedItems = await dequeue(SYNC_CONFIG.MAX_BATCH_SIZE);
if (queuedItems.length === 0) {
return result;
}
options.onProgress?.({
phase: "pushing",
current: 0,
total: queuedItems.length,
message: CLOUD_MESSAGES.PUSHING(queuedItems.length),
});
const items = queuedItems.map((q) => q.item);
try {
const response = await pushToCloud(authToken, projectId, items);
if (response.success) {
result.itemsSynced = response.accepted;
result.itemsFailed = response.rejected;
// Mark successful items as processed
const successIds = queuedItems
.slice(0, response.accepted)
.map((q) => q.id);
await markProcessed(successIds);
// Handle conflicts
for (const conflict of response.conflicts) {
result.conflicts.push(conflict);
}
syncState.lastPushAt = Date.now();
} else {
result.success = false;
result.errors.push(...(response.errors ?? []));
// Mark all as failed
await markFailed(
queuedItems.map((q) => q.id),
response.errors?.[0],
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result.success = false;
result.errors.push(CLOUD_ERRORS.PUSH_FAILED(message));
// Queue for retry
await markFailed(
queuedItems.map((q) => q.id),
message,
);
}
return result;
};
/**
* Pull remote changes from cloud
*/
const pullChanges = async (
authToken: string,
projectId: number,
options: SyncOptions,
): Promise<Omit<SyncResult, "direction" | "timestamp">> => {
const result = {
success: true,
itemsSynced: 0,
itemsFailed: 0,
conflicts: [] as SyncResult["conflicts"],
errors: [] as string[],
duration: 0,
};
try {
const response = await pullFromCloud(
authToken,
projectId,
localVersion,
syncState.lastPullAt ?? 0,
);
if (response.success) {
options.onProgress?.({
phase: "pulling",
current: response.items.length,
total: response.items.length,
message: CLOUD_MESSAGES.PULLING(response.items.length),
});
// Process pulled items
for (const item of response.items) {
// Check for conflicts with local changes
const hasConflict = await checkLocalConflict(item);
if (hasConflict) {
// Create conflict entry
const localItem = await getLocalItem(item.id, item.type);
if (localItem) {
const conflict = createConflict(localItem, item);
result.conflicts.push(conflict);
}
} else {
// Apply remote change locally
await applyRemoteChange(item);
result.itemsSynced++;
}
}
// Update local version
localVersion = response.serverVersion;
syncState.lastPullAt = Date.now();
} else {
result.success = false;
result.errors.push(...(response.errors ?? []));
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
result.success = false;
result.errors.push(CLOUD_ERRORS.PULL_FAILED(message));
}
return result;
};
/**
* Push items to cloud API
*/
const pushToCloud = async (
authToken: string,
projectId: number,
items: SyncItem[],
): Promise<PushResponse> => {
const url = `${cloudConfig.endpoint}${CLOUD_ENDPOINTS.PUSH}`;
const request: PushRequest = {
items,
projectId,
clientVersion: "1.0.0",
};
const response = await fetch(url, {
method: "POST",
headers: {
...CLOUD_HTTP_CONFIG.HEADERS,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(CLOUD_HTTP_CONFIG.TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<PushResponse>;
};
/**
* Pull items from cloud API
*/
const pullFromCloud = async (
authToken: string,
projectId: number,
sinceVersion: number,
sinceTimestamp: number,
): Promise<PullResponse> => {
const url = `${cloudConfig.endpoint}${CLOUD_ENDPOINTS.PULL}`;
const request: PullRequest = {
projectId,
sinceVersion,
sinceTimestamp,
limit: SYNC_CONFIG.MAX_BATCH_SIZE,
};
const response = await fetch(url, {
method: "POST",
headers: {
...CLOUD_HTTP_CONFIG.HEADERS,
Authorization: `Bearer ${authToken}`,
},
body: JSON.stringify(request),
signal: AbortSignal.timeout(CLOUD_HTTP_CONFIG.TIMEOUT_MS),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json() as Promise<PullResponse>;
};
/**
* Check if pulled item conflicts with local changes
*/
const checkLocalConflict = async (
_item: SyncItem,
): Promise<boolean> => {
// Check if we have pending changes for this item
const queued = await hasQueuedItems();
return queued;
};
/**
* Get local item by ID and type
*/
const getLocalItem = async (
_id: string,
_type: "concept" | "memory" | "relation",
): Promise<SyncItem | null> => {
// This would retrieve the local item from the brain service
// Placeholder implementation
return null;
};
/**
* Apply a remote change locally
*/
const applyRemoteChange = async (_item: SyncItem): Promise<void> => {
// This would apply the change to the local brain storage
// Placeholder implementation
};
/**
* Queue a change for sync
*/
export const queueChange = async (item: SyncItem): Promise<void> => {
await enqueue(item);
syncState.pendingChanges = await getQueueSize();
syncState.status = "pending";
};
/**
* Queue multiple changes
*/
export const queueChanges = async (items: SyncItem[]): Promise<number> => {
const added = await enqueueBatch(items);
syncState.pendingChanges = await getQueueSize();
syncState.status = "pending";
return added;
};
/**
* Force sync now
*/
export const syncNow = async (
authToken: string,
projectId: number,
): Promise<SyncResult> => {
return sync(authToken, projectId, { force: true });
};
/**
* Reset sync state
*/
export const resetSyncState = async (): Promise<void> => {
await clearQueue();
syncState = {
status: "synced",
lastSyncAt: null,
lastPushAt: null,
lastPullAt: null,
pendingChanges: 0,
conflictCount: 0,
syncErrors: [],
};
localVersion = 0;
};

View File

@@ -0,0 +1,249 @@
/**
* Conflict Resolver
*
* Handles sync conflicts between local and remote brain data.
*/
import {
CONFLICT_LABELS,
} from "@constants/brain-cloud";
import type {
SyncConflict,
ConflictStrategy,
SyncItem,
} from "@/types/brain-cloud";
// In-memory conflict storage
const pendingConflicts = new Map<string, SyncConflict>();
/**
* Create a conflict from local and remote items
*/
export const createConflict = (
localItem: SyncItem,
remoteItem: SyncItem,
): SyncConflict => {
const conflict: SyncConflict = {
id: generateConflictId(),
itemId: localItem.id,
itemType: localItem.type,
localData: localItem.data,
remoteData: remoteItem.data,
localVersion: localItem.localVersion,
remoteVersion: remoteItem.remoteVersion ?? 0,
localTimestamp: localItem.timestamp,
remoteTimestamp: remoteItem.timestamp,
resolved: false,
};
pendingConflicts.set(conflict.id, conflict);
return conflict;
};
/**
* Resolve a conflict using the specified strategy
*/
export const resolveConflict = (
conflictId: string,
strategy: ConflictStrategy,
): SyncConflict | null => {
const conflict = pendingConflicts.get(conflictId);
if (!conflict) return null;
const resolver = resolvers[strategy];
const resolvedData = resolver(conflict);
conflict.resolved = true;
conflict.resolution = strategy;
conflict.resolvedData = resolvedData;
return conflict;
};
/**
* Resolve all pending conflicts with a single strategy
*/
export const resolveAllConflicts = (
strategy: ConflictStrategy,
): SyncConflict[] => {
const resolved: SyncConflict[] = [];
for (const [id, conflict] of pendingConflicts) {
if (!conflict.resolved) {
const result = resolveConflict(id, strategy);
if (result) {
resolved.push(result);
}
}
}
return resolved;
};
/**
* Conflict resolution strategies
*/
const resolvers: Record<ConflictStrategy, (conflict: SyncConflict) => unknown> = {
"local-wins": (conflict) => conflict.localData,
"remote-wins": (conflict) => conflict.remoteData,
manual: (_conflict) => {
// Manual resolution returns null - requires user input
return null;
},
merge: (conflict) => {
// Attempt to merge the data
return mergeData(conflict.localData, conflict.remoteData);
},
};
/**
* Attempt to merge two data objects
*/
const mergeData = (local: unknown, remote: unknown): unknown => {
// If both are objects, merge their properties
if (isObject(local) && isObject(remote)) {
const localObj = local as Record<string, unknown>;
const remoteObj = remote as Record<string, unknown>;
const merged: Record<string, unknown> = { ...remoteObj };
for (const key of Object.keys(localObj)) {
// Local wins for non-timestamp fields that differ
if (key !== "updatedAt" && key !== "timestamp") {
merged[key] = localObj[key];
}
}
// Use most recent timestamp
const localTime = (localObj.updatedAt ?? localObj.timestamp ?? 0) as number;
const remoteTime = (remoteObj.updatedAt ?? remoteObj.timestamp ?? 0) as number;
merged.updatedAt = Math.max(localTime, remoteTime);
return merged;
}
// For non-objects, prefer local (or most recent)
return local;
};
/**
* Check if value is an object
*/
const isObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
/**
* Get pending conflicts
*/
export const getPendingConflicts = (): SyncConflict[] => {
return Array.from(pendingConflicts.values()).filter((c) => !c.resolved);
};
/**
* Get all conflicts
*/
export const getAllConflicts = (): SyncConflict[] => {
return Array.from(pendingConflicts.values());
};
/**
* Get conflict by ID
*/
export const getConflict = (id: string): SyncConflict | undefined => {
return pendingConflicts.get(id);
};
/**
* Clear resolved conflicts
*/
export const clearResolvedConflicts = (): number => {
let cleared = 0;
for (const [id, conflict] of pendingConflicts) {
if (conflict.resolved) {
pendingConflicts.delete(id);
cleared++;
}
}
return cleared;
};
/**
* Clear all conflicts
*/
export const clearAllConflicts = (): void => {
pendingConflicts.clear();
};
/**
* Get conflict count
*/
export const getConflictCount = (): number => {
return getPendingConflicts().length;
};
/**
* Check if there are unresolved conflicts
*/
export const hasUnresolvedConflicts = (): boolean => {
return getPendingConflicts().length > 0;
};
/**
* Get suggested resolution for a conflict
*/
export const suggestResolution = (conflict: SyncConflict): ConflictStrategy => {
// If remote is newer, suggest remote-wins
if (conflict.remoteTimestamp > conflict.localTimestamp) {
return "remote-wins";
}
// If local is newer, suggest local-wins
if (conflict.localTimestamp > conflict.remoteTimestamp) {
return "local-wins";
}
// If timestamps are equal, try merge
return "merge";
};
/**
* Format conflict for display
*/
export const formatConflict = (conflict: SyncConflict): string => {
const lines: string[] = [];
lines.push(`**Conflict: ${conflict.itemId}**`);
lines.push(`Type: ${conflict.itemType}`);
lines.push(`Local version: ${conflict.localVersion}`);
lines.push(`Remote version: ${conflict.remoteVersion}`);
lines.push("");
lines.push("Local data:");
lines.push("```json");
lines.push(JSON.stringify(conflict.localData, null, 2));
lines.push("```");
lines.push("");
lines.push("Remote data:");
lines.push("```json");
lines.push(JSON.stringify(conflict.remoteData, null, 2));
lines.push("```");
if (conflict.resolved) {
lines.push("");
lines.push(`Resolution: ${CONFLICT_LABELS[conflict.resolution!]}`);
}
return lines.join("\n");
};
/**
* Generate unique conflict ID
*/
const generateConflictId = (): string => {
return `conflict_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};

View File

@@ -0,0 +1,354 @@
/**
* Brain MCP Server service
* Exposes Brain as an MCP server for external tools
*/
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
import { randomUUID } from "node:crypto";
import type {
BrainMcpServerConfig,
BrainMcpRequest,
BrainMcpResponse,
BrainMcpServerStatus,
BrainMcpToolName,
McpContent,
McpError,
} from "@src/types/brain-mcp";
import {
DEFAULT_BRAIN_MCP_SERVER_CONFIG,
BRAIN_MCP_TOOLS,
MCP_ERROR_CODES,
} from "@src/types/brain-mcp";
import {
BRAIN_MCP_SERVER,
BRAIN_MCP_MESSAGES,
BRAIN_MCP_ERRORS,
BRAIN_MCP_AUTH,
} from "@src/constants/brain-mcp";
type BrainService = {
recall: (query: string, limit?: number) => Promise<unknown>;
learn: (name: string, whatItDoes: string, options?: unknown) => Promise<unknown>;
searchMemories: (query: string, limit?: number, type?: string) => Promise<unknown>;
relate: (source: string, target: string, type: string, weight?: number) => Promise<unknown>;
getContext: (query: string, maxConcepts?: number) => Promise<string>;
getStats: () => Promise<unknown>;
isConnected: () => boolean;
};
interface McpServerState {
server: Server | null;
config: BrainMcpServerConfig;
brainService: BrainService | null;
connectedClients: number;
startTime: number | null;
requestsServed: number;
lastRequestAt: number | null;
rateLimitMap: Map<string, { count: number; resetAt: number }>;
apiKeys: Set<string>;
}
const state: McpServerState = {
server: null,
config: DEFAULT_BRAIN_MCP_SERVER_CONFIG,
brainService: null,
connectedClients: 0,
startTime: null,
requestsServed: 0,
lastRequestAt: null,
rateLimitMap: new Map(),
apiKeys: new Set(),
};
const createMcpError = (code: number, message: string, data?: unknown): McpError => ({
code,
message,
data,
});
const createMcpResponse = (
id: string | number,
content?: ReadonlyArray<McpContent>,
error?: McpError
): BrainMcpResponse => {
if (error) {
return { id, error };
}
return {
id,
result: {
content: content || [],
},
};
};
const checkRateLimit = (clientIp: string): boolean => {
if (!state.config.rateLimit.enabled) return true;
const now = Date.now();
const clientLimit = state.rateLimitMap.get(clientIp);
if (!clientLimit || now > clientLimit.resetAt) {
state.rateLimitMap.set(clientIp, {
count: 1,
resetAt: now + state.config.rateLimit.windowMs,
});
return true;
}
if (clientLimit.count >= state.config.rateLimit.maxRequests) {
return false;
}
state.rateLimitMap.set(clientIp, {
...clientLimit,
count: clientLimit.count + 1,
});
return true;
};
const validateApiKey = (req: IncomingMessage): boolean => {
if (!state.config.enableAuth) return true;
const apiKey = req.headers[state.config.apiKeyHeader.toLowerCase()] as string | undefined;
if (!apiKey) return false;
// If no API keys configured, accept any key for now
if (state.apiKeys.size === 0) return true;
return state.apiKeys.has(apiKey);
};
const handleToolCall = async (
toolName: BrainMcpToolName,
args: Record<string, unknown>
): Promise<McpContent[]> => {
if (!state.brainService) {
throw createMcpError(MCP_ERROR_CODES.BRAIN_UNAVAILABLE, BRAIN_MCP_MESSAGES.SERVER_NOT_RUNNING);
}
if (!state.brainService.isConnected()) {
throw createMcpError(MCP_ERROR_CODES.BRAIN_UNAVAILABLE, "Brain service not connected");
}
const tool = BRAIN_MCP_TOOLS.find((t) => t.name === toolName);
if (!tool) {
throw createMcpError(MCP_ERROR_CODES.TOOL_NOT_FOUND, `Tool not found: ${toolName}`);
}
let result: unknown;
const toolHandlers: Record<BrainMcpToolName, () => Promise<unknown>> = {
brain_recall: () => state.brainService!.recall(args.query as string, args.limit as number | undefined),
brain_learn: () => state.brainService!.learn(
args.name as string,
args.whatItDoes as string,
{ keywords: args.keywords, patterns: args.patterns, files: args.files }
),
brain_search: () => state.brainService!.searchMemories(
args.query as string,
args.limit as number | undefined,
args.type as string | undefined
),
brain_relate: () => state.brainService!.relate(
args.sourceConcept as string,
args.targetConcept as string,
args.relationType as string,
args.weight as number | undefined
),
brain_context: () => state.brainService!.getContext(
args.query as string,
args.maxConcepts as number | undefined
),
brain_stats: () => state.brainService!.getStats(),
brain_projects: async () => {
// Import dynamically to avoid circular dependency
const { listProjects } = await import("@src/services/brain/project-service");
return listProjects();
},
};
const handler = toolHandlers[toolName];
if (!handler) {
throw createMcpError(MCP_ERROR_CODES.TOOL_NOT_FOUND, `No handler for tool: ${toolName}`);
}
result = await handler();
return [
{
type: "text",
text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
},
];
};
const handleRequest = async (
req: IncomingMessage,
res: ServerResponse
): Promise<void> => {
// Set CORS headers
res.setHeader("Access-Control-Allow-Origin", state.config.allowedOrigins.join(","));
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", `Content-Type, ${state.config.apiKeyHeader}`);
// Handle preflight
if (req.method === "OPTIONS") {
res.writeHead(204);
res.end();
return;
}
if (req.method !== "POST") {
res.writeHead(405);
res.end(JSON.stringify(createMcpResponse("", undefined, BRAIN_MCP_ERRORS.INVALID_REQUEST)));
return;
}
// Get client IP for rate limiting
const clientIp = req.socket.remoteAddress || "unknown";
// Check rate limit
if (!checkRateLimit(clientIp)) {
res.writeHead(429);
res.end(JSON.stringify(createMcpResponse("", undefined, BRAIN_MCP_ERRORS.RATE_LIMITED)));
return;
}
// Validate API key
if (!validateApiKey(req)) {
res.writeHead(401);
res.end(JSON.stringify(createMcpResponse("", undefined, BRAIN_MCP_ERRORS.UNAUTHORIZED)));
return;
}
// Parse request body
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", async () => {
state.requestsServed++;
state.lastRequestAt = Date.now();
let mcpRequest: BrainMcpRequest;
try {
mcpRequest = JSON.parse(body) as BrainMcpRequest;
} catch {
res.writeHead(400);
res.end(JSON.stringify(createMcpResponse("", undefined, BRAIN_MCP_ERRORS.PARSE_ERROR)));
return;
}
// Handle MCP request
try {
if (mcpRequest.method === "tools/call") {
const { name, arguments: args } = mcpRequest.params;
const content = await handleToolCall(name, args);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify(createMcpResponse(mcpRequest.id, content)));
} else if (mcpRequest.method === "tools/list") {
const tools = BRAIN_MCP_TOOLS.map((tool) => ({
name: tool.name,
description: tool.description,
inputSchema: tool.inputSchema,
}));
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
id: mcpRequest.id,
result: { tools },
}));
} else {
res.writeHead(400);
res.end(JSON.stringify(createMcpResponse(mcpRequest.id, undefined, BRAIN_MCP_ERRORS.METHOD_NOT_FOUND)));
}
} catch (error) {
const mcpError = error instanceof Object && "code" in error
? error as McpError
: createMcpError(MCP_ERROR_CODES.INTERNAL_ERROR, error instanceof Error ? error.message : "Unknown error");
res.writeHead(500);
res.end(JSON.stringify(createMcpResponse(mcpRequest.id, undefined, mcpError)));
}
});
};
// Public API
export const start = async (
brainService: BrainService,
config?: Partial<BrainMcpServerConfig>
): Promise<void> => {
if (state.server) {
throw new Error(BRAIN_MCP_MESSAGES.SERVER_ALREADY_RUNNING);
}
state.config = { ...DEFAULT_BRAIN_MCP_SERVER_CONFIG, ...config };
state.brainService = brainService;
return new Promise((resolve, reject) => {
state.server = createServer(handleRequest);
state.server.on("error", (error) => {
state.server = null;
reject(error);
});
state.server.listen(state.config.port, state.config.host, () => {
state.startTime = Date.now();
state.requestsServed = 0;
resolve();
});
});
};
export const stop = async (): Promise<void> => {
if (!state.server) {
return;
}
return new Promise((resolve) => {
state.server!.close(() => {
state.server = null;
state.startTime = null;
state.connectedClients = 0;
state.brainService = null;
resolve();
});
});
};
export const getStatus = (): BrainMcpServerStatus => ({
running: state.server !== null,
port: state.config.port,
host: state.config.host,
connectedClients: state.connectedClients,
uptime: state.startTime ? Date.now() - state.startTime : 0,
requestsServed: state.requestsServed,
lastRequestAt: state.lastRequestAt || undefined,
});
export const addApiKey = (key: string): void => {
state.apiKeys.add(key);
};
export const removeApiKey = (key: string): void => {
state.apiKeys.delete(key);
};
export const isRunning = (): boolean => state.server !== null;
export const getConfig = (): BrainMcpServerConfig => ({ ...state.config });
export const updateConfig = (config: Partial<BrainMcpServerConfig>): void => {
state.config = { ...state.config, ...config };
};
export const getAvailableTools = (): ReadonlyArray<{ name: string; description: string }> =>
BRAIN_MCP_TOOLS.map((t) => ({ name: t.name, description: t.description }));

View File

@@ -0,0 +1,270 @@
/**
* Offline Queue
*
* Manages queued changes when offline for later synchronization.
*/
import fs from "fs/promises";
import { join } from "path";
import { DIRS } from "@constants/paths";
import { SYNC_CONFIG, CLOUD_ERRORS } from "@constants/brain-cloud";
import type {
SyncItem,
OfflineQueueItem,
OfflineQueueState,
SyncOperationType,
} from "@/types/brain-cloud";
// Queue file path
const getQueuePath = (): string => join(DIRS.data, "brain-offline-queue.json");
// In-memory queue state
let queueState: OfflineQueueState = {
items: [],
totalSize: 0,
oldestItem: null,
};
let loaded = false;
/**
* Load queue from disk
*/
export const loadQueue = async (): Promise<void> => {
if (loaded) return;
try {
const data = await fs.readFile(getQueuePath(), "utf-8");
const parsed = JSON.parse(data) as OfflineQueueState;
queueState = parsed;
loaded = true;
} catch {
// File doesn't exist or is invalid, start fresh
queueState = {
items: [],
totalSize: 0,
oldestItem: null,
};
loaded = true;
}
};
/**
* Save queue to disk
*/
const saveQueue = async (): Promise<void> => {
try {
await fs.mkdir(DIRS.data, { recursive: true });
await fs.writeFile(getQueuePath(), JSON.stringify(queueState, null, 2));
} catch (error) {
console.error("Failed to save offline queue:", error);
}
};
/**
* Add item to offline queue
*/
export const enqueue = async (item: SyncItem): Promise<boolean> => {
await loadQueue();
// Check queue size limit
if (queueState.items.length >= SYNC_CONFIG.MAX_QUEUE_SIZE) {
throw new Error(CLOUD_ERRORS.QUEUE_FULL);
}
const queueItem: OfflineQueueItem = {
id: generateQueueId(),
item,
retryCount: 0,
lastAttempt: 0,
};
queueState.items.push(queueItem);
queueState.totalSize = queueState.items.length;
queueState.oldestItem = Math.min(
queueState.oldestItem ?? item.timestamp,
item.timestamp,
);
await saveQueue();
return true;
};
/**
* Add multiple items to queue
*/
export const enqueueBatch = async (items: SyncItem[]): Promise<number> => {
await loadQueue();
let added = 0;
for (const item of items) {
if (queueState.items.length >= SYNC_CONFIG.MAX_QUEUE_SIZE) {
break;
}
const queueItem: OfflineQueueItem = {
id: generateQueueId(),
item,
retryCount: 0,
lastAttempt: 0,
};
queueState.items.push(queueItem);
added++;
}
queueState.totalSize = queueState.items.length;
if (added > 0) {
queueState.oldestItem = Math.min(
queueState.oldestItem ?? Date.now(),
...items.map((i) => i.timestamp),
);
}
await saveQueue();
return added;
};
/**
* Get items from queue for processing
*/
export const dequeue = async (limit: number = SYNC_CONFIG.MAX_BATCH_SIZE): Promise<OfflineQueueItem[]> => {
await loadQueue();
// Get items that haven't exceeded retry limit
const available = queueState.items.filter(
(item) => item.retryCount < SYNC_CONFIG.MAX_QUEUE_SIZE,
);
return available.slice(0, limit);
};
/**
* Mark items as processed (remove from queue)
*/
export const markProcessed = async (ids: string[]): Promise<void> => {
await loadQueue();
const idSet = new Set(ids);
queueState.items = queueState.items.filter((item) => !idSet.has(item.id));
queueState.totalSize = queueState.items.length;
// Update oldest item
if (queueState.items.length > 0) {
queueState.oldestItem = Math.min(
...queueState.items.map((i) => i.item.timestamp),
);
} else {
queueState.oldestItem = null;
}
await saveQueue();
};
/**
* Mark items as failed (increment retry count)
*/
export const markFailed = async (
ids: string[],
error?: string,
): Promise<void> => {
await loadQueue();
const now = Date.now();
for (const id of ids) {
const item = queueState.items.find((i) => i.id === id);
if (item) {
item.retryCount++;
item.lastAttempt = now;
item.error = error;
}
}
await saveQueue();
};
/**
* Get queue state
*/
export const getQueueState = async (): Promise<OfflineQueueState> => {
await loadQueue();
return { ...queueState };
};
/**
* Get queue size
*/
export const getQueueSize = async (): Promise<number> => {
await loadQueue();
return queueState.items.length;
};
/**
* Check if queue has items
*/
export const hasQueuedItems = async (): Promise<boolean> => {
await loadQueue();
return queueState.items.length > 0;
};
/**
* Clear the entire queue
*/
export const clearQueue = async (): Promise<void> => {
queueState = {
items: [],
totalSize: 0,
oldestItem: null,
};
await saveQueue();
};
/**
* Remove stale items from queue
*/
export const pruneStaleItems = async (): Promise<number> => {
await loadQueue();
const cutoff = Date.now() - SYNC_CONFIG.STALE_ITEM_AGE_MS;
const before = queueState.items.length;
queueState.items = queueState.items.filter(
(item) => item.item.timestamp > cutoff,
);
queueState.totalSize = queueState.items.length;
const removed = before - queueState.items.length;
if (removed > 0) {
await saveQueue();
}
return removed;
};
/**
* Get items by type
*/
export const getItemsByType = async (
type: "concept" | "memory" | "relation",
): Promise<OfflineQueueItem[]> => {
await loadQueue();
return queueState.items.filter((item) => item.item.type === type);
};
/**
* Get items by operation
*/
export const getItemsByOperation = async (
operation: SyncOperationType,
): Promise<OfflineQueueItem[]> => {
await loadQueue();
return queueState.items.filter((item) => item.item.operation === operation);
};
/**
* Generate unique queue item ID
*/
const generateQueueId = (): string => {
return `q_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
};

View File

@@ -0,0 +1,384 @@
/**
* Brain project service
* Manages multiple Brain projects/knowledge bases
*/
import { writeFile, readFile, mkdir } from "node:fs/promises";
import { join } from "node:path";
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import type {
BrainProject,
BrainProjectStats,
BrainProjectSettings,
BrainProjectCreateInput,
BrainProjectUpdateInput,
BrainProjectSwitchResult,
BrainProjectListResult,
BrainProjectExport,
BrainProjectImportResult,
ExportedConcept,
ExportedMemory,
ExportedRelationship,
} from "@src/types/brain-project";
import {
DEFAULT_BRAIN_PROJECT_SETTINGS,
BRAIN_PROJECT_EXPORT_VERSION,
} from "@src/types/brain-project";
import {
BRAIN_PROJECT,
BRAIN_PROJECT_STORAGE,
BRAIN_PROJECT_PATHS,
BRAIN_PROJECT_MESSAGES,
BRAIN_PROJECT_API,
} from "@src/constants/brain-project";
interface ProjectServiceState {
projects: Map<number, BrainProject>;
activeProjectId: number | null;
configPath: string;
initialized: boolean;
}
const state: ProjectServiceState = {
projects: new Map(),
activeProjectId: null,
configPath: join(homedir(), ".local", "share", "codetyper", BRAIN_PROJECT_STORAGE.CONFIG_FILE),
initialized: false,
};
const ensureDirectories = async (): Promise<void> => {
const paths = [
join(homedir(), ".local", "share", "codetyper", "brain"),
join(homedir(), ".local", "share", "codetyper", "brain", "exports"),
join(homedir(), ".local", "share", "codetyper", "brain", "backups"),
];
for (const path of paths) {
if (!existsSync(path)) {
await mkdir(path, { recursive: true });
}
}
};
const loadProjectsFromConfig = async (): Promise<void> => {
if (!existsSync(state.configPath)) {
return;
}
try {
const content = await readFile(state.configPath, "utf-8");
const data = JSON.parse(content) as {
projects: BrainProject[];
activeProjectId: number | null;
};
state.projects.clear();
data.projects.forEach((project) => {
state.projects.set(project.id, project);
});
state.activeProjectId = data.activeProjectId;
} catch {
// Config file corrupted, start fresh
state.projects.clear();
state.activeProjectId = null;
}
};
const saveProjectsToConfig = async (): Promise<void> => {
await ensureDirectories();
const data = {
projects: Array.from(state.projects.values()),
activeProjectId: state.activeProjectId,
version: "1.0.0",
updatedAt: Date.now(),
};
await writeFile(state.configPath, JSON.stringify(data, null, 2));
};
const generateProjectId = (): number => {
const existingIds = Array.from(state.projects.keys());
return existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
};
const createDefaultStats = (): BrainProjectStats => ({
conceptCount: 0,
memoryCount: 0,
relationshipCount: 0,
totalTokensUsed: 0,
});
// Public API
export const initialize = async (): Promise<void> => {
if (state.initialized) return;
await ensureDirectories();
await loadProjectsFromConfig();
state.initialized = true;
};
export const createProject = async (input: BrainProjectCreateInput): Promise<BrainProject> => {
await initialize();
// Validate name
if (input.name.length < BRAIN_PROJECT.NAME_MIN_LENGTH) {
throw new Error(BRAIN_PROJECT_MESSAGES.INVALID_NAME);
}
if (input.name.length > BRAIN_PROJECT.NAME_MAX_LENGTH) {
throw new Error(BRAIN_PROJECT_MESSAGES.INVALID_NAME);
}
// Check for duplicate names
const existingProject = Array.from(state.projects.values()).find(
(p) => p.name.toLowerCase() === input.name.toLowerCase()
);
if (existingProject) {
throw new Error(BRAIN_PROJECT_MESSAGES.ALREADY_EXISTS);
}
const now = Date.now();
const project: BrainProject = {
id: generateProjectId(),
name: input.name,
description: input.description || "",
rootPath: input.rootPath,
createdAt: now,
updatedAt: now,
stats: createDefaultStats(),
settings: {
...DEFAULT_BRAIN_PROJECT_SETTINGS,
...input.settings,
},
isActive: false,
};
state.projects.set(project.id, project);
await saveProjectsToConfig();
return project;
};
export const updateProject = async (
projectId: number,
input: BrainProjectUpdateInput
): Promise<BrainProject> => {
await initialize();
const project = state.projects.get(projectId);
if (!project) {
throw new Error(BRAIN_PROJECT_MESSAGES.NOT_FOUND);
}
const updatedProject: BrainProject = {
...project,
name: input.name ?? project.name,
description: input.description ?? project.description,
settings: input.settings
? { ...project.settings, ...input.settings }
: project.settings,
updatedAt: Date.now(),
};
state.projects.set(projectId, updatedProject);
await saveProjectsToConfig();
return updatedProject;
};
export const deleteProject = async (projectId: number): Promise<boolean> => {
await initialize();
const project = state.projects.get(projectId);
if (!project) {
return false;
}
// Can't delete active project
if (state.activeProjectId === projectId) {
state.activeProjectId = null;
}
state.projects.delete(projectId);
await saveProjectsToConfig();
return true;
};
export const switchProject = async (projectId: number): Promise<BrainProjectSwitchResult> => {
await initialize();
const newProject = state.projects.get(projectId);
if (!newProject) {
throw new Error(BRAIN_PROJECT_MESSAGES.NOT_FOUND);
}
const previousProject = state.activeProjectId
? state.projects.get(state.activeProjectId)
: undefined;
// Update active status
if (previousProject) {
state.projects.set(previousProject.id, { ...previousProject, isActive: false });
}
state.projects.set(projectId, { ...newProject, isActive: true });
state.activeProjectId = projectId;
await saveProjectsToConfig();
return {
success: true,
previousProject,
currentProject: state.projects.get(projectId)!,
message: `${BRAIN_PROJECT_MESSAGES.SWITCHED} "${newProject.name}"`,
};
};
export const getProject = async (projectId: number): Promise<BrainProject | undefined> => {
await initialize();
return state.projects.get(projectId);
};
export const getActiveProject = async (): Promise<BrainProject | undefined> => {
await initialize();
return state.activeProjectId ? state.projects.get(state.activeProjectId) : undefined;
};
export const listProjects = async (): Promise<BrainProjectListResult> => {
await initialize();
return {
projects: Array.from(state.projects.values()).sort((a, b) => b.updatedAt - a.updatedAt),
activeProjectId: state.activeProjectId ?? undefined,
total: state.projects.size,
};
};
export const findProjectByPath = async (rootPath: string): Promise<BrainProject | undefined> => {
await initialize();
return Array.from(state.projects.values()).find((p) => p.rootPath === rootPath);
};
export const updateProjectStats = async (
projectId: number,
stats: Partial<BrainProjectStats>
): Promise<void> => {
await initialize();
const project = state.projects.get(projectId);
if (!project) return;
const updatedProject: BrainProject = {
...project,
stats: { ...project.stats, ...stats },
updatedAt: Date.now(),
};
state.projects.set(projectId, updatedProject);
await saveProjectsToConfig();
};
export const exportProject = async (projectId: number): Promise<BrainProjectExport> => {
await initialize();
const project = state.projects.get(projectId);
if (!project) {
throw new Error(BRAIN_PROJECT_MESSAGES.NOT_FOUND);
}
// In a real implementation, this would fetch data from Brain API
// For now, return structure with empty data
const exportData: BrainProjectExport = {
project,
concepts: [],
memories: [],
relationships: [],
exportedAt: Date.now(),
version: BRAIN_PROJECT_EXPORT_VERSION,
};
// Save export file
const exportPath = join(
homedir(),
".local",
"share",
"codetyper",
"brain",
"exports",
`${project.name}-${Date.now()}${BRAIN_PROJECT_STORAGE.EXPORT_EXTENSION}`
);
await writeFile(exportPath, JSON.stringify(exportData, null, 2));
return exportData;
};
export const importProject = async (
exportData: BrainProjectExport
): Promise<BrainProjectImportResult> => {
await initialize();
try {
// Create new project with imported data
const newProject = await createProject({
name: `${exportData.project.name} (imported)`,
description: exportData.project.description,
rootPath: exportData.project.rootPath,
settings: exportData.project.settings,
});
// In a real implementation, this would send data to Brain API
// For now, just return success with counts
return {
success: true,
project: newProject,
imported: {
concepts: exportData.concepts.length,
memories: exportData.memories.length,
relationships: exportData.relationships.length,
},
errors: [],
};
} catch (error) {
return {
success: false,
project: exportData.project,
imported: { concepts: 0, memories: 0, relationships: 0 },
errors: [error instanceof Error ? error.message : "Import failed"],
};
}
};
export const getProjectSettings = async (projectId: number): Promise<BrainProjectSettings | undefined> => {
await initialize();
const project = state.projects.get(projectId);
return project?.settings;
};
export const updateProjectSettings = async (
projectId: number,
settings: Partial<BrainProjectSettings>
): Promise<BrainProjectSettings> => {
const project = await updateProject(projectId, { settings });
return project.settings;
};
export const setActiveProjectByPath = async (rootPath: string): Promise<BrainProject | undefined> => {
const project = await findProjectByPath(rootPath);
if (project) {
await switchProject(project.id);
return project;
}
return undefined;
};