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:
523
src/services/brain/cloud-sync.ts
Normal file
523
src/services/brain/cloud-sync.ts
Normal 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;
|
||||
};
|
||||
249
src/services/brain/conflict-resolver.ts
Normal file
249
src/services/brain/conflict-resolver.ts
Normal 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)}`;
|
||||
};
|
||||
354
src/services/brain/mcp-server.ts
Normal file
354
src/services/brain/mcp-server.ts
Normal 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 }));
|
||||
270
src/services/brain/offline-queue.ts
Normal file
270
src/services/brain/offline-queue.ts
Normal 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)}`;
|
||||
};
|
||||
384
src/services/brain/project-service.ts
Normal file
384
src/services/brain/project-service.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user