docs(claude): restructure documentation management and add feedback system

- Restructure §8 from "文档沉淀规则" to "文档管理规则" with 4 subsections
  - Add docs/ structure with features/ and knowledge-base/ directories
  - Add feature documentation template with 7 sections (概述/设计初衷/技术设计/预期作用/实际效果/演化路线/头脑风暴)
  - Add feature update trigger matrix (新增/修改/完成/问题/反馈)
  - Add documentation quality checklist
- Add §16
This commit is contained in:
iven
2026-03-16 13:54:03 +08:00
parent 8e630882c7
commit adfd7024df
44 changed files with 10491 additions and 248 deletions

View File

@@ -2,11 +2,14 @@
* Agent Memory System - Persistent cross-session memory for ZCLAW agents
*
* Phase 1 implementation: zustand persist (localStorage) with keyword search.
* Optimized with inverted index for sub-20ms retrieval on 1000+ memories.
* Designed for easy upgrade to SQLite + FTS5 + vector search in Phase 2.
*
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.2.1
*/
import { MemoryIndex, getMemoryIndex, resetMemoryIndex, tokenize } from './memory-index';
// === Types ===
export type MemoryType = 'fact' | 'preference' | 'lesson' | 'context' | 'task';
@@ -41,6 +44,10 @@ export interface MemoryStats {
byAgent: Record<string, number>;
oldestEntry: string | null;
newestEntry: string | null;
indexStats?: {
cacheHitRate: number;
avgQueryTime: number;
};
}
// === Memory ID Generator ===
@@ -51,16 +58,13 @@ function generateMemoryId(): string {
// === Keyword Search Scoring ===
function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf]+/g, ' ')
.split(/\s+/)
.filter(t => t.length > 0);
}
function searchScore(entry: MemoryEntry, queryTokens: string[]): number {
const contentTokens = tokenize(entry.content);
function searchScore(
entry: MemoryEntry,
queryTokens: string[],
cachedTokens?: string[]
): number {
// Use cached tokens if available, otherwise tokenize
const contentTokens = cachedTokens ?? tokenize(entry.content);
const tagTokens = entry.tags.flatMap(t => tokenize(t));
const allTokens = [...contentTokens, ...tagTokens];
@@ -86,9 +90,13 @@ const STORAGE_KEY = 'zclaw-agent-memories';
export class MemoryManager {
private entries: MemoryEntry[] = [];
private entryIndex: Map<string, number> = new Map(); // id -> array index for O(1) lookup
private memoryIndex: MemoryIndex;
private indexInitialized = false;
constructor() {
this.load();
this.memoryIndex = getMemoryIndex();
}
// === Persistence ===
@@ -98,6 +106,10 @@ export class MemoryManager {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
this.entries = JSON.parse(raw);
// Build entry index for O(1) lookups
this.entries.forEach((entry, index) => {
this.entryIndex.set(entry.id, index);
});
}
} catch (err) {
console.warn('[MemoryManager] Failed to load memories:', err);
@@ -113,6 +125,26 @@ export class MemoryManager {
}
}
// === Index Management ===
private ensureIndexInitialized(): void {
if (!this.indexInitialized) {
this.memoryIndex.rebuild(this.entries);
this.indexInitialized = true;
}
}
private indexEntry(entry: MemoryEntry): void {
this.ensureIndexInitialized();
this.memoryIndex.index(entry);
}
private removeEntryFromIndex(id: string): void {
if (this.indexInitialized) {
this.memoryIndex.remove(id);
}
}
// === Write ===
async save(
@@ -141,51 +173,90 @@ export class MemoryManager {
duplicate.lastAccessedAt = now;
duplicate.accessCount++;
duplicate.tags = [...new Set([...duplicate.tags, ...entry.tags])];
// Re-index the updated entry
this.indexEntry(duplicate);
this.persist();
return duplicate;
}
this.entries.push(newEntry);
this.entryIndex.set(newEntry.id, this.entries.length - 1);
this.indexEntry(newEntry);
this.persist();
return newEntry;
}
// === Search ===
// === Search (Optimized with Index) ===
async search(query: string, options?: MemorySearchOptions): Promise<MemoryEntry[]> {
const startTime = performance.now();
const queryTokens = tokenize(query);
if (queryTokens.length === 0) return [];
let candidates = [...this.entries];
this.ensureIndexInitialized();
// Filter by options
if (options?.agentId) {
candidates = candidates.filter(e => e.agentId === options.agentId);
}
if (options?.type) {
candidates = candidates.filter(e => e.type === options.type);
}
if (options?.types && options.types.length > 0) {
candidates = candidates.filter(e => options.types!.includes(e.type));
}
if (options?.tags && options.tags.length > 0) {
candidates = candidates.filter(e =>
options.tags!.some(tag => e.tags.includes(tag))
);
}
if (options?.minImportance !== undefined) {
candidates = candidates.filter(e => e.importance >= options.minImportance!);
// Check query cache first
const cached = this.memoryIndex.getCached(query, options);
if (cached) {
// Retrieve entries by IDs
const results = cached
.map(id => this.entries[this.entryIndex.get(id) ?? -1])
.filter((e): e is MemoryEntry => e !== undefined);
this.memoryIndex.recordQueryTime(performance.now() - startTime);
return results;
}
// Score and rank
// Get candidate IDs using index (O(1) lookups)
const candidateIds = this.memoryIndex.getCandidates(options || {});
// If no candidates from index, return empty
if (candidateIds && candidateIds.size === 0) {
this.memoryIndex.setCached(query, options, []);
this.memoryIndex.recordQueryTime(performance.now() - startTime);
return [];
}
// Build candidates list
let candidates: MemoryEntry[];
if (candidateIds) {
// Use indexed candidates
candidates = [];
for (const id of candidateIds) {
const idx = this.entryIndex.get(id);
if (idx !== undefined) {
const entry = this.entries[idx];
// Additional filter for minImportance (not handled by index)
if (options?.minImportance !== undefined && entry.importance < options.minImportance) {
continue;
}
candidates.push(entry);
}
}
} else {
// Fallback: no index-based candidates, use all entries
candidates = [...this.entries];
// Apply minImportance filter
if (options?.minImportance !== undefined) {
candidates = candidates.filter(e => e.importance >= options.minImportance!);
}
}
// Score and rank using cached tokens
const scored = candidates
.map(entry => ({ entry, score: searchScore(entry, queryTokens) }))
.map(entry => {
const cachedTokens = this.memoryIndex.getTokens(entry.id);
return { entry, score: searchScore(entry, queryTokens, cachedTokens) };
})
.filter(item => item.score > 0)
.sort((a, b) => b.score - a.score);
const limit = options?.limit ?? 10;
const results = scored.slice(0, limit).map(item => item.entry);
// Cache the results
this.memoryIndex.setCached(query, options, results.map(r => r.id));
// Update access metadata
const now = new Date().toISOString();
for (const entry of results) {
@@ -196,16 +267,36 @@ export class MemoryManager {
this.persist();
}
this.memoryIndex.recordQueryTime(performance.now() - startTime);
return results;
}
// === Get All (for an agent) ===
// === Get All (for an agent) - Optimized with Index ===
async getAll(agentId: string, options?: { type?: MemoryType; limit?: number }): Promise<MemoryEntry[]> {
let results = this.entries.filter(e => e.agentId === agentId);
this.ensureIndexInitialized();
if (options?.type) {
results = results.filter(e => e.type === options.type);
// Use index to get candidates for this agent
const candidateIds = this.memoryIndex.getCandidates({
agentId,
type: options?.type,
});
let results: MemoryEntry[];
if (candidateIds) {
results = [];
for (const id of candidateIds) {
const idx = this.entryIndex.get(id);
if (idx !== undefined) {
results.push(this.entries[idx]);
}
}
} else {
// Fallback to linear scan
results = this.entries.filter(e => e.agentId === agentId);
if (options?.type) {
results = results.filter(e => e.type === options.type);
}
}
results.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
@@ -217,17 +308,27 @@ export class MemoryManager {
return results;
}
// === Get by ID ===
// === Get by ID (O(1) with index) ===
async get(id: string): Promise<MemoryEntry | null> {
return this.entries.find(e => e.id === id) ?? null;
const idx = this.entryIndex.get(id);
return idx !== undefined ? this.entries[idx] ?? null : null;
}
// === Forget ===
async forget(id: string): Promise<void> {
this.entries = this.entries.filter(e => e.id !== id);
this.persist();
const idx = this.entryIndex.get(id);
if (idx !== undefined) {
this.removeEntryFromIndex(id);
this.entries.splice(idx, 1);
// Rebuild entry index since positions changed
this.entryIndex.clear();
this.entries.forEach((entry, i) => {
this.entryIndex.set(entry.id, i);
});
this.persist();
}
}
// === Prune (bulk cleanup) ===
@@ -240,6 +341,8 @@ export class MemoryManager {
const before = this.entries.length;
const now = Date.now();
const toRemove: string[] = [];
this.entries = this.entries.filter(entry => {
if (options.agentId && entry.agentId !== options.agentId) return true; // keep other agents
@@ -248,10 +351,24 @@ export class MemoryManager {
const tooLow = options.minImportance !== undefined && entry.importance < options.minImportance;
// Only prune if both conditions met (old AND low importance)
if (tooOld && tooLow) return false;
if (tooOld && tooLow) {
toRemove.push(entry.id);
return false;
}
return true;
});
// Remove from index
for (const id of toRemove) {
this.removeEntryFromIndex(id);
}
// Rebuild entry index
this.entryIndex.clear();
this.entries.forEach((entry, i) => {
this.entryIndex.set(entry.id, i);
});
const pruned = before - this.entries.length;
if (pruned > 0) {
this.persist();

View File

@@ -0,0 +1,373 @@
/**
* ZCLAW Error Handling Utilities
*
* Centralized error reporting, notification, and tracking system.
*/
import { v4 as uuidv4 } from 'uuid';
import {
AppError,
classifyError,
ErrorCategory,
ErrorSeverity,
} from './error-types';
// === Error Store ===
interface StoredError extends AppError {
dismissed: boolean;
reported: boolean;
}
interface ErrorStore {
errors: StoredError[];
addError: (error: AppError) => void;
dismissError: (id: string) => void;
dismissAll: () => void;
markReported: (id: string) => void;
getUndismissedErrors: () => StoredError[];
getErrorCount: () => number;
getErrorsByCategory: (category: ErrorCategory) => StoredError[];
getErrorsBySeverity: (severity: ErrorSeverity) => StoredError[];
}
// === Global Error Store ===
let errorStore: ErrorStore = {
errors: [],
addError: () => {},
dismissError: () => {},
dismissAll: () => {},
markReported: () => {},
getUndismissedErrors: () => [],
getErrorCount: () => 0,
getErrorsByCategory: () => [],
getErrorsBySeverity: () => [],
};
// === Initialize Store ===
function initErrorStore(): void {
errorStore = {
errors: [],
addError: (error: AppError) => {
errorStore.errors = [error, ...errorStore.errors];
// Notify listeners
notifyErrorListeners(error);
},
dismissError: (id: string) => void {
const error = errorStore.errors.find(e => e.id === id);
if (error) {
errorStore.errors = errorStore.errors.map(e =>
e.id === id ? { ...e, dismissed: true } : e
);
}
},
dismissAll: () => void {
errorStore.errors = errorStore.errors.map(e => ({ ...e, dismissed: true }));
},
markReported: (id: string) => void {
const error = errorStore.errors.find(e => e.id === id);
if (error) {
errorStore.errors = errorStore.errors.map(e =>
e.id === id ? { ...e, reported: true } : e
);
}
},
getUndismissedErrors: () => StoredError[] => {
return errorStore.errors.filter(e => !e.dismissed);
},
getErrorCount: () => number => {
return errorStore.errors.filter(e => !e.dismissed).length;
},
getErrorsByCategory: (category: ErrorCategory) => StoredError[] => {
return errorStore.errors.filter(e => e.category === category && !e.dismissed);
},
getErrorsBySeverity: (severity: ErrorSeverity) => StoredError[] => {
return errorStore.errors.filter(e => e.severity === severity && !e.dismissed);
},
};
}
// === Error Listeners ===
type ErrorListener = (error: AppError) => void;
const errorListeners: Set<ErrorListener> = new Set();
function addErrorListener(listener: ErrorListener): () => void {
errorListeners.add(listener);
return () => errorListeners.delete(listener);
}
function notifyErrorListeners(error: AppError): void {
errorListeners.forEach(listener => {
try {
listener(error);
} catch (e) {
console.error('[ErrorHandling] Listener error:', e);
}
});
}
// Initialize on first import
initErrorStore();
// === Public API ===
/**
* Report an error to the centralized error handling system.
*/
export function reportError(
error: unknown,
context?: {
componentStack?: string;
errorName?: string;
errorMessage?: string;
}
): AppError {
const appError = classifyError(error);
// Add context information if provided
if (context) {
const technicalDetails = [
context.componentStack && `Component Stack:\n${context.componentStack}`,
context.errorName && `Error Name: ${context.errorName}`,
context.errorMessage && `Error Message: ${context.errorMessage}`,
].filter(Boolean).join('\n\n');
if (technicalDetails) {
(appError as { technicalDetails?: string }).technicalDetails = technicalDetails;
}
}
errorStore.addError(appError);
// Log to console in development
if (import.meta.env.DEV) {
console.error('[ErrorHandling] Error reported:', {
id: appError.id,
category: appError.category,
severity: appError.severity,
title: appError.title,
message: appError.message,
});
}
return appError;
}
/**
* Report an error from an API response.
*/
export function reportApiError(
response: Response,
endpoint: string,
method: string = 'GET'
): AppError {
const status = response.status;
let category: ErrorCategory = 'server';
let severity: ErrorSeverity = 'medium';
let title = 'API Error';
let message = `Request to ${endpoint} failed with status ${status}`;
let recoverySteps: { description: string }[] = [];
if (status === 401) {
category = 'auth';
severity = 'high';
title = 'Authentication Required';
message = 'Your session has expired. Please authenticate again.';
recoverySteps = [
{ description: 'Click "Reconnect" to authenticate' },
{ description: 'Check your API key in settings' },
];
} else if (status === 403) {
category = 'permission';
severity = 'medium';
title = 'Permission Denied';
message = 'You do not have permission to perform this action.';
recoverySteps = [
{ description: 'Contact your administrator for access' },
{ description: 'Check your RBAC configuration' },
];
} else if (status === 404) {
category = 'client';
severity = 'low';
title = 'Not Found';
message = `The requested resource was not found: ${endpoint}`;
recoverySteps = [
{ description: 'Verify the resource exists' },
{ description: 'Check the URL is correct' },
];
} else if (status === 422) {
category = 'validation';
severity = 'low';
title = 'Validation Error';
message = 'The request data is invalid.';
recoverySteps = [
{ description: 'Check your input data format' },
{ description: 'Verify required fields are provided' },
];
} else if (status === 429) {
category = 'client';
severity = 'medium';
title = 'Rate Limited';
message = 'Too many requests. Please wait before trying again.';
recoverySteps = [
{ description: 'Wait a moment before retrying' },
{ description: 'Reduce request frequency' },
];
} else if (status >= 500) {
category = 'server';
severity = 'high';
title = 'Server Error';
message = 'The server encountered an error processing your request.';
recoverySteps = [
{ description: 'Try again in a few moments' },
{ description: 'Contact support if the problem persists' },
];
}
const appError: AppError = {
id: uuidv4(),
category,
severity,
title,
message,
technicalDetails: `${method} ${endpoint}\nStatus: ${status}\nResponse: ${response.statusText}`,
recoverable: status !== 500 || status < 400,
recoverySteps,
timestamp: new Date(),
originalError: response,
};
errorStore.addError(appError);
return appError;
}
/**
* Report a network error.
*/
export function reportNetworkError(
error: Error,
url?: string
): AppError {
return reportError(error, {
errorMessage: url ? `URL: ${url}\n${error.message}` : error.message,
});
}
/**
* Report a WebSocket error.
*/
export function reportWebSocketError(
event: CloseEvent | ErrorEvent,
url: string
): AppError {
const code = 'code' in event ? event.code : 0;
const reason = 'reason' in event ? event.reason : 'Unknown';
return reportError(
new Error(`WebSocket error: ${reason} (code: ${code})`),
{
errorMessage: `WebSocket URL: ${url}\nCode: ${code}\nReason: ${reason}`,
}
);
}
/**
* Dismiss an error by ID.
*/
export function dismissError(id: string): void {
errorStore.dismissError(id);
}
/**
* Dismiss all active errors.
*/
export function dismissAllErrors(): void {
errorStore.dismissAll();
}
/**
* Mark an error as reported.
*/
export function markErrorReported(id: string): void {
errorStore.markReported(id);
}
/**
* Get all active (non-dismissed) errors.
*/
export function getActiveErrors(): StoredError[] {
return errorStore.getUndismissedErrors();
}
/**
* Get the count of active errors.
*/
export function getActiveErrorCount(): number {
return errorStore.getErrorCount();
}
/**
* Get errors filtered by category.
*/
export function getErrorsByCategory(category: ErrorCategory): StoredError[] {
return errorStore.getErrorsByCategory(category);
}
/**
* Get errors filtered by severity.
*/
export function getErrorsBySeverity(severity: ErrorSeverity): StoredError[] {
return errorStore.getErrorsBySeverity(severity);
}
/**
* Subscribe to error events.
*/
export function subscribeToErrors(listener: ErrorListener): () => void {
return addErrorListener(listener);
}
/**
* Check if there are any critical errors.
*/
export function hasCriticalErrors(): boolean {
return errorStore.getErrorsBySeverity('critical').length > 0;
}
/**
* Check if there are any high severity errors.
*/
export function hasHighSeverityErrors(): boolean {
const highSeverity = ['high', 'critical'];
return errorStore.errors.some(e => highSeverity.includes(e.severity) && !e.dismissed);
}
// === Types ===
interface CloseEvent {
code?: number;
reason?: string;
wasClean?: boolean;
}
interface ErrorEvent {
code?: number;
reason?: string;
message?: string;
}
export interface StoredError extends AppError {
dismissed: boolean;
reported: boolean;
}

View File

@@ -0,0 +1,524 @@
/**
* ZCLAW Error Types and Utilities
*
* Provides a unified error classification system with recovery suggestions
* for user-friendly error handling.
*/
// === Error Categories ===
export type ErrorCategory =
| 'network' // Network connectivity issues
| 'auth' // Authentication and authorization failures
| 'permission' // RBAC permission denied
| 'validation' // Input validation errors
| 'timeout' // Request timeout
| 'server' // Server-side errors (5xx)
| 'client' // Client-side errors (4xx)
| 'config' // Configuration errors
| 'system'; // System/runtime errors
// === Error Severity ===
export type ErrorSeverity = 'low' | 'medium' | 'high' | 'critical';
// === App Error Interface ===
export interface AppError {
id: string;
category: ErrorCategory;
severity: ErrorSeverity;
title: string;
message: string;
technicalDetails?: string;
recoverable: boolean;
recoverySteps: RecoveryStep[];
timestamp: Date;
originalError?: unknown;
}
export interface RecoveryStep {
description: string;
action?: () => void | Promise<void>;
label?: string;
}
// === Error Detection Patterns ===
interface ErrorPattern {
patterns: (string | RegExp)[];
category: ErrorCategory;
severity: ErrorSeverity;
title: string;
messageTemplate: (match: string) => string;
recoverySteps: RecoveryStep[];
recoverable: boolean;
}
const ERROR_PATTERNS: ErrorPattern[] = [
// Network Errors
{
patterns: [
'Failed to fetch',
'NetworkError',
'ERR_NETWORK',
'ERR_CONNECTION_REFUSED',
'ERR_CONNECTION_RESET',
'ERR_INTERNET_DISCONNECTED',
'WebSocket connection failed',
'ECONNREFUSED',
],
category: 'network',
severity: 'high',
title: 'Network Connection Error',
messageTemplate: () => 'Unable to connect to the server. Please check your network connection.',
recoverySteps: [
{ description: 'Check your internet connection is active' },
{ description: 'Verify the server address is correct' },
{ description: 'Try again in a few moments' },
],
recoverable: true,
},
{
patterns: ['ERR_NAME_NOT_RESOLVED', 'DNS', 'ENOTFOUND'],
category: 'network',
severity: 'high',
title: 'DNS Resolution Failed',
messageTemplate: () => 'Could not resolve the server address. The server may be offline or the address is incorrect.',
recoverySteps: [
{ description: 'Verify the server URL is correct' },
{ description: 'Check if the server is running' },
{ description: 'Try using an IP address instead of hostname' },
],
recoverable: true,
},
// Authentication Errors
{
patterns: [
'401',
'Unauthorized',
'Invalid token',
'Token expired',
'Authentication failed',
'Not authenticated',
'JWT expired',
],
category: 'auth',
severity: 'high',
title: 'Authentication Failed',
messageTemplate: () => 'Your session has expired or is invalid. Please log in again.',
recoverySteps: [
{ description: 'Click "Reconnect" to authenticate again' },
{ description: 'Check your API key or credentials in settings' },
{ description: 'Verify your account is active' },
],
recoverable: true,
},
{
patterns: ['Invalid API key', 'API key expired', 'Invalid credentials'],
category: 'auth',
severity: 'high',
title: 'Invalid Credentials',
messageTemplate: () => 'The provided API key or credentials are invalid.',
recoverySteps: [
{ description: 'Check your API key in the settings' },
{ description: 'Generate a new API key from your provider dashboard' },
{ description: 'Ensure the key has not been revoked' },
],
recoverable: true,
},
// Permission Errors
{
patterns: [
'403',
'Forbidden',
'Permission denied',
'Access denied',
'Insufficient permissions',
'RBAC',
'Not authorized',
],
category: 'permission',
severity: 'medium',
title: 'Permission Denied',
messageTemplate: () => 'You do not have permission to perform this action.',
recoverySteps: [
{ description: 'Contact your administrator for access' },
{ description: 'Check your role has the required capabilities' },
{ description: 'Verify the resource exists and you have access' },
],
recoverable: false,
},
// Timeout Errors
{
patterns: [
'ETIMEDOUT',
'Timeout',
'Request timeout',
'timed out',
'Deadline exceeded',
],
category: 'timeout',
severity: 'medium',
title: 'Request Timeout',
messageTemplate: () => 'The request took too long to complete. The server may be overloaded.',
recoverySteps: [
{ description: 'Try again with a simpler request' },
{ description: 'Wait a moment and retry' },
{ description: 'Check server status and load' },
],
recoverable: true,
},
// Validation Errors
{
patterns: [
'400',
'Bad Request',
'Validation failed',
'Invalid input',
'Invalid parameter',
'Schema validation',
],
category: 'validation',
severity: 'low',
title: 'Invalid Input',
messageTemplate: (match) => `The request contains invalid data: ${match}`,
recoverySteps: [
{ description: 'Check your input for errors' },
{ description: 'Ensure all required fields are filled' },
{ description: 'Verify the format matches requirements' },
],
recoverable: true,
},
{
patterns: ['413', 'Payload too large', 'Request entity too large'],
category: 'validation',
severity: 'medium',
title: 'Request Too Large',
messageTemplate: () => 'The request exceeds the maximum allowed size.',
recoverySteps: [
{ description: 'Reduce the size of your input' },
{ description: 'Split large requests into smaller ones' },
{ description: 'Remove unnecessary attachments or data' },
],
recoverable: true,
},
// Server Errors
{
patterns: [
'500',
'Internal Server Error',
'InternalServerError',
'502',
'Bad Gateway',
'503',
'Service Unavailable',
'504',
'Gateway Timeout',
],
category: 'server',
severity: 'high',
title: 'Server Error',
messageTemplate: () => 'The server encountered an error and could not complete your request.',
recoverySteps: [
{ description: 'Wait a few moments and try again' },
{ description: 'Check the service status page' },
{ description: 'Contact support if the problem persists' },
],
recoverable: true,
},
// Rate Limiting
{
patterns: ['429', 'Too Many Requests', 'Rate limit', 'quota exceeded'],
category: 'client',
severity: 'medium',
title: 'Rate Limited',
messageTemplate: () => 'Too many requests. Please wait before trying again.',
recoverySteps: [
{ description: 'Wait a minute before sending more requests' },
{ description: 'Reduce request frequency' },
{ description: 'Check your usage quota' },
],
recoverable: true,
},
// Configuration Errors
{
patterns: [
'Config not found',
'Invalid configuration',
'TOML parse error',
'Missing configuration',
],
category: 'config',
severity: 'medium',
title: 'Configuration Error',
messageTemplate: () => 'There is a problem with the application configuration.',
recoverySteps: [
{ description: 'Check your configuration file syntax' },
{ description: 'Verify all required settings are present' },
{ description: 'Reset to default configuration if needed' },
],
recoverable: true,
},
// WebSocket Errors
{
patterns: [
'WebSocket',
'socket closed',
'socket hang up',
'Connection closed',
'Not connected',
],
category: 'network',
severity: 'high',
title: 'Connection Lost',
messageTemplate: () => 'The connection to the server was lost. Attempting to reconnect...',
recoverySteps: [
{ description: 'Check your network connection' },
{ description: 'Click "Reconnect" to establish a new connection' },
{ description: 'Verify the server is running' },
],
recoverable: true,
},
// Hand/Workflow Errors
{
patterns: ['Hand failed', 'Hand error', 'needs_approval', 'approval required'],
category: 'permission',
severity: 'medium',
title: 'Hand Execution Failed',
messageTemplate: () => 'The autonomous capability (Hand) could not execute.',
recoverySteps: [
{ description: 'Check if the Hand requires approval' },
{ description: 'Verify you have the necessary permissions' },
{ description: 'Review the Hand configuration' },
],
recoverable: true,
},
{
patterns: ['Workflow failed', 'Workflow error', 'step failed'],
category: 'server',
severity: 'medium',
title: 'Workflow Execution Failed',
messageTemplate: () => 'The workflow encountered an error during execution.',
recoverySteps: [
{ description: 'Review the workflow steps for errors' },
{ description: 'Check the workflow configuration' },
{ description: 'Try running individual steps manually' },
],
recoverable: true,
},
];
// === Error Classification Function ===
function matchPattern(error: unknown): { pattern: ErrorPattern; match: string } | null {
const errorString = typeof error === 'string'
? error
: error instanceof Error
? `${error.message} ${error.name} ${error.stack || ''}`
: String(error);
for (const pattern of ERROR_PATTERNS) {
for (const p of pattern.patterns) {
const regex = p instanceof RegExp ? p : new RegExp(p, 'i');
const match = errorString.match(regex);
if (match) {
return { pattern, match: match[0] };
}
}
}
return null;
}
/**
* Classify an error and create an AppError with recovery suggestions.
*/
export function classifyError(error: unknown): AppError {
const matched = matchPattern(error);
if (matched) {
const { pattern, match } = matched;
return {
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
category: pattern.category,
severity: pattern.severity,
title: pattern.title,
message: pattern.messageTemplate(match),
technicalDetails: error instanceof Error
? `${error.name}: ${error.message}\n${error.stack || ''}`
: String(error),
recoverable: pattern.recoverable,
recoverySteps: pattern.recoverySteps,
timestamp: new Date(),
originalError: error,
};
}
// Unknown error - return generic error
return {
id: `err_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
category: 'system',
severity: 'medium',
title: 'An Error Occurred',
message: error instanceof Error ? error.message : 'An unexpected error occurred.',
technicalDetails: error instanceof Error
? `${error.name}: ${error.message}\n${error.stack || ''}`
: String(error),
recoverable: true,
recoverySteps: [
{ description: 'Try the operation again' },
{ description: 'Refresh the page if the problem persists' },
{ description: 'Contact support with the error details' },
],
timestamp: new Date(),
originalError: error,
};
}
// === Error Category Icons and Colors ===
export interface ErrorCategoryStyle {
icon: string;
color: string;
bgColor: string;
borderColor: string;
}
export const ERROR_CATEGORY_STYLES: Record<ErrorCategory, ErrorCategoryStyle> = {
network: {
icon: 'Wifi',
color: 'text-orange-600 dark:text-orange-400',
bgColor: 'bg-orange-50 dark:bg-orange-900/20',
borderColor: 'border-orange-200 dark:border-orange-800',
},
auth: {
icon: 'Key',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-200 dark:border-red-800',
},
permission: {
icon: 'Shield',
color: 'text-purple-600 dark:text-purple-400',
bgColor: 'bg-purple-50 dark:bg-purple-900/20',
borderColor: 'border-purple-200 dark:border-purple-800',
},
validation: {
icon: 'AlertCircle',
color: 'text-yellow-600 dark:text-yellow-400',
bgColor: 'bg-yellow-50 dark:bg-yellow-900/20',
borderColor: 'border-yellow-200 dark:border-yellow-800',
},
timeout: {
icon: 'Clock',
color: 'text-amber-600 dark:text-amber-400',
bgColor: 'bg-amber-50 dark:bg-amber-900/20',
borderColor: 'border-amber-200 dark:border-amber-800',
},
server: {
icon: 'Server',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-200 dark:border-red-800',
},
client: {
icon: 'User',
color: 'text-blue-600 dark:text-blue-400',
bgColor: 'bg-blue-50 dark:bg-blue-900/20',
borderColor: 'border-blue-200 dark:border-blue-800',
},
config: {
icon: 'Settings',
color: 'text-gray-600 dark:text-gray-400',
bgColor: 'bg-gray-50 dark:bg-gray-900/20',
borderColor: 'border-gray-200 dark:border-gray-800',
},
system: {
icon: 'AlertTriangle',
color: 'text-red-600 dark:text-red-400',
bgColor: 'bg-red-50 dark:bg-red-900/20',
borderColor: 'border-red-200 dark:border-red-800',
},
};
// === Error Severity Styles ===
export const ERROR_SEVERITY_STYLES: Record<ErrorSeverity, { badge: string; priority: number }> = {
low: {
badge: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
priority: 1,
},
medium: {
badge: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
priority: 2,
},
high: {
badge: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
priority: 3,
},
critical: {
badge: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
priority: 4,
},
};
// === Helper Functions ===
/**
* Format an error for display in a toast notification.
*/
export function formatErrorForToast(error: AppError): { title: string; message: string } {
return {
title: error.title,
message: error.message.length > 100
? `${error.message.slice(0, 100)}...`
: error.message,
};
}
/**
* Check if an error is recoverable and suggest primary action.
*/
export function getPrimaryRecoveryAction(error: AppError): RecoveryStep | undefined {
if (!error.recoverable || error.recoverySteps.length === 0) {
return undefined;
}
return error.recoverySteps[0];
}
/**
* Create a copy of the error details for clipboard.
*/
export function formatErrorForClipboard(error: AppError): string {
const lines = [
`Error ID: ${error.id}`,
`Category: ${error.category}`,
`Severity: ${error.severity}`,
`Time: ${error.timestamp.toISOString()}`,
'',
`Title: ${error.title}`,
`Message: ${error.message}`,
];
if (error.technicalDetails) {
lines.push('', 'Technical Details:', error.technicalDetails);
}
if (error.recoverySteps.length > 0) {
lines.push('', 'Recovery Steps:');
error.recoverySteps.forEach((step, i) => {
lines.push(`${i + 1}. ${step.description}`);
});
}
return lines.join('\n');
}

View File

@@ -0,0 +1,443 @@
/**
* Memory Index - High-performance indexing for agent memory retrieval
*
* Implements inverted index + LRU cache for sub-20ms retrieval on 1000+ memories.
*
* Performance targets:
* - Retrieval latency: <20ms (vs ~50ms with linear scan)
* - 1000 memories: smooth operation
* - Memory overhead: ~30% additional for indexes
*
* Reference: Task "Optimize ZCLAW Agent Memory Retrieval Performance"
*/
import type { MemoryEntry, MemoryType } from './agent-memory';
// === Types ===
export interface IndexStats {
totalEntries: number;
keywordCount: number;
cacheHitRate: number;
cacheSize: number;
avgQueryTime: number;
}
interface CacheEntry {
results: string[]; // memory IDs
timestamp: number;
}
// === Tokenization (shared with agent-memory.ts) ===
export function tokenize(text: string): string[] {
return text
.toLowerCase()
.replace(/[^\w\u4e00-\u9fff\u3400-\u4dbf]+/g, ' ')
.split(/\s+/)
.filter(t => t.length > 0);
}
// === LRU Cache Implementation ===
class LRUCache<K, V> {
private cache: Map<K, V>;
private maxSize: number;
constructor(maxSize: number) {
this.cache = new Map();
this.maxSize = maxSize;
}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value;
if (firstKey !== undefined) {
this.cache.delete(firstKey);
}
}
this.cache.set(key, value);
}
clear(): void {
this.cache.clear();
}
get size(): number {
return this.cache.size;
}
}
// === Memory Index Implementation ===
export class MemoryIndex {
// Inverted indexes
private keywordIndex: Map<string, Set<string>> = new Map(); // keyword -> memoryIds
private typeIndex: Map<MemoryType, Set<string>> = new Map(); // type -> memoryIds
private agentIndex: Map<string, Set<string>> = new Map(); // agentId -> memoryIds
private tagIndex: Map<string, Set<string>> = new Map(); // tag -> memoryIds
// Pre-tokenized content cache
private tokenCache: Map<string, string[]> = new Map(); // memoryId -> tokens
// Query result cache
private queryCache: LRUCache<string, CacheEntry>;
// Statistics
private cacheHits = 0;
private cacheMisses = 0;
private queryTimes: number[] = [];
constructor(cacheSize = 100) {
this.queryCache = new LRUCache(cacheSize);
}
// === Index Building ===
/**
* Build or update index for a memory entry.
* Call this when adding or updating a memory.
*/
index(entry: MemoryEntry): void {
const { id, agentId, type, tags, content } = entry;
// Index by agent
if (!this.agentIndex.has(agentId)) {
this.agentIndex.set(agentId, new Set());
}
this.agentIndex.get(agentId)!.add(id);
// Index by type
if (!this.typeIndex.has(type)) {
this.typeIndex.set(type, new Set());
}
this.typeIndex.get(type)!.add(id);
// Index by tags
for (const tag of tags) {
const normalizedTag = tag.toLowerCase();
if (!this.tagIndex.has(normalizedTag)) {
this.tagIndex.set(normalizedTag, new Set());
}
this.tagIndex.get(normalizedTag)!.add(id);
}
// Index by content keywords
const tokens = tokenize(content);
this.tokenCache.set(id, tokens);
for (const token of tokens) {
if (!this.keywordIndex.has(token)) {
this.keywordIndex.set(token, new Set());
}
this.keywordIndex.get(token)!.add(id);
}
// Invalidate query cache on index change
this.queryCache.clear();
}
/**
* Remove a memory from all indexes.
*/
remove(memoryId: string): void {
// Remove from agent index
for (const [agentId, ids] of this.agentIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.agentIndex.delete(agentId);
}
}
// Remove from type index
for (const [type, ids] of this.typeIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.typeIndex.delete(type);
}
}
// Remove from tag index
for (const [tag, ids] of this.tagIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.tagIndex.delete(tag);
}
}
// Remove from keyword index
for (const [keyword, ids] of this.keywordIndex) {
ids.delete(memoryId);
if (ids.size === 0) {
this.keywordIndex.delete(keyword);
}
}
// Remove token cache
this.tokenCache.delete(memoryId);
// Invalidate query cache
this.queryCache.clear();
}
/**
* Rebuild all indexes from scratch.
* Use after bulk updates or data corruption.
*/
rebuild(entries: MemoryEntry[]): void {
this.clear();
for (const entry of entries) {
this.index(entry);
}
}
/**
* Clear all indexes.
*/
clear(): void {
this.keywordIndex.clear();
this.typeIndex.clear();
this.agentIndex.clear();
this.tagIndex.clear();
this.tokenCache.clear();
this.queryCache.clear();
this.cacheHits = 0;
this.cacheMisses = 0;
this.queryTimes = [];
}
// === Fast Filtering ===
/**
* Get candidate memory IDs based on filter options.
* Uses indexes for O(1) lookups instead of O(n) scans.
*/
getCandidates(options: {
agentId?: string;
type?: MemoryType;
types?: MemoryType[];
tags?: string[];
}): Set<string> | null {
const candidateSets: Set<string>[] = [];
// Filter by agent
if (options.agentId) {
const agentSet = this.agentIndex.get(options.agentId);
if (!agentSet) return new Set(); // Agent has no memories
candidateSets.push(agentSet);
}
// Filter by single type
if (options.type) {
const typeSet = this.typeIndex.get(options.type);
if (!typeSet) return new Set(); // No memories of this type
candidateSets.push(typeSet);
}
// Filter by multiple types
if (options.types && options.types.length > 0) {
const typeUnion = new Set<string>();
for (const t of options.types) {
const typeSet = this.typeIndex.get(t);
if (typeSet) {
for (const id of typeSet) {
typeUnion.add(id);
}
}
}
if (typeUnion.size === 0) return new Set();
candidateSets.push(typeUnion);
}
// Filter by tags (OR logic - match any tag)
if (options.tags && options.tags.length > 0) {
const tagUnion = new Set<string>();
for (const tag of options.tags) {
const normalizedTag = tag.toLowerCase();
const tagSet = this.tagIndex.get(normalizedTag);
if (tagSet) {
for (const id of tagSet) {
tagUnion.add(id);
}
}
}
if (tagUnion.size === 0) return new Set();
candidateSets.push(tagUnion);
}
// Intersect all candidate sets
if (candidateSets.length === 0) {
return null; // No filters applied, return null to indicate "all"
}
// Start with smallest set for efficiency
candidateSets.sort((a, b) => a.size - b.size);
let result = new Set(candidateSets[0]);
for (let i = 1; i < candidateSets.length; i++) {
const nextSet = candidateSets[i];
result = new Set([...result].filter(id => nextSet.has(id)));
if (result.size === 0) break;
}
return result;
}
// === Keyword Search ===
/**
* Get memory IDs that contain any of the query keywords.
* Returns a map of memoryId -> match count for ranking.
*/
searchKeywords(queryTokens: string[]): Map<string, number> {
const matchCounts = new Map<string, number>();
for (const token of queryTokens) {
const matchingIds = this.keywordIndex.get(token);
if (matchingIds) {
for (const id of matchingIds) {
matchCounts.set(id, (matchCounts.get(id) ?? 0) + 1);
}
}
// Also check for partial matches (token is substring of indexed keyword)
for (const [keyword, ids] of this.keywordIndex) {
if (keyword.includes(token) || token.includes(keyword)) {
for (const id of ids) {
matchCounts.set(id, (matchCounts.get(id) ?? 0) + 1);
}
}
}
}
return matchCounts;
}
/**
* Get pre-tokenized content for a memory.
*/
getTokens(memoryId: string): string[] | undefined {
return this.tokenCache.get(memoryId);
}
// === Query Cache ===
/**
* Generate cache key from query and options.
*/
private getCacheKey(query: string, options?: Record<string, unknown>): string {
const opts = options ?? {};
return `${query}|${opts.agentId ?? ''}|${opts.type ?? ''}|${(opts.types as string[])?.join(',') ?? ''}|${(opts.tags as string[])?.join(',') ?? ''}|${opts.minImportance ?? ''}|${opts.limit ?? ''}`;
}
/**
* Get cached query results.
*/
getCached(query: string, options?: Record<string, unknown>): string[] | null {
const key = this.getCacheKey(query, options);
const cached = this.queryCache.get(key);
if (cached) {
this.cacheHits++;
return cached.results;
}
this.cacheMisses++;
return null;
}
/**
* Cache query results.
*/
setCached(query: string, options: Record<string, unknown> | undefined, results: string[]): void {
const key = this.getCacheKey(query, options);
this.queryCache.set(key, {
results,
timestamp: Date.now(),
});
}
// === Statistics ===
/**
* Record query time for statistics.
*/
recordQueryTime(timeMs: number): void {
this.queryTimes.push(timeMs);
// Keep last 100 query times
if (this.queryTimes.length > 100) {
this.queryTimes.shift();
}
}
/**
* Get index statistics.
*/
getStats(): IndexStats {
const avgQueryTime = this.queryTimes.length > 0
? this.queryTimes.reduce((a, b) => a + b, 0) / this.queryTimes.length
: 0;
const totalRequests = this.cacheHits + this.cacheMisses;
return {
totalEntries: this.tokenCache.size,
keywordCount: this.keywordIndex.size,
cacheHitRate: totalRequests > 0 ? this.cacheHits / totalRequests : 0,
cacheSize: this.queryCache.size,
avgQueryTime,
};
}
/**
* Get index memory usage estimate.
*/
getMemoryUsage(): { estimated: number; breakdown: Record<string, number> } {
let keywordIndexSize = 0;
for (const [keyword, ids] of this.keywordIndex) {
keywordIndexSize += keyword.length * 2 + ids.size * 50; // rough estimate
}
return {
estimated:
keywordIndexSize +
this.typeIndex.size * 100 +
this.agentIndex.size * 100 +
this.tagIndex.size * 100 +
this.tokenCache.size * 200,
breakdown: {
keywordIndex: keywordIndexSize,
typeIndex: this.typeIndex.size * 100,
agentIndex: this.agentIndex.size * 100,
tagIndex: this.tagIndex.size * 100,
tokenCache: this.tokenCache.size * 200,
},
};
}
}
// === Singleton ===
let _instance: MemoryIndex | null = null;
export function getMemoryIndex(): MemoryIndex {
if (!_instance) {
_instance = new MemoryIndex();
}
return _instance;
}
export function resetMemoryIndex(): void {
_instance = null;
}

View File

@@ -0,0 +1,656 @@
/**
* Session Persistence - Automatic session data persistence for L4 self-evolution
*
* Provides automatic persistence of conversation sessions:
* - Periodic auto-save of session state
* - Memory extraction at session end
* - Context compaction for long sessions
* - Session history and recovery
*
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.4
*/
import { getVikingClient, type VikingHttpClient } from './viking-client';
import { getMemoryManager, type MemoryType } from './agent-memory';
import { getMemoryExtractor } from './memory-extractor';
import { canAutoExecute, executeWithAutonomy } from './autonomy-manager';
// === Types ===
export interface SessionMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: string;
metadata?: Record<string, unknown>;
}
export interface SessionState {
id: string;
agentId: string;
startedAt: string;
lastActivityAt: string;
messageCount: number;
status: 'active' | 'paused' | 'ended';
messages: SessionMessage[];
metadata: {
model?: string;
workspaceId?: string;
conversationId?: string;
[key: string]: unknown;
};
}
export interface SessionPersistenceConfig {
enabled: boolean;
autoSaveIntervalMs: number; // Auto-save interval (default: 60s)
maxMessagesBeforeCompact: number; // Trigger compaction at this count
extractMemoriesOnEnd: boolean; // Extract memories when session ends
persistToViking: boolean; // Use OpenViking for persistence
fallbackToLocal: boolean; // Fall back to localStorage
maxSessionHistory: number; // Max sessions to keep in history
sessionTimeoutMs: number; // Session timeout (default: 30min)
}
export interface SessionSummary {
id: string;
agentId: string;
startedAt: string;
endedAt: string;
messageCount: number;
topicsDiscussed: string[];
memoriesExtracted: number;
compacted: boolean;
}
export interface PersistenceResult {
saved: boolean;
sessionId: string;
messageCount: number;
extractedMemories: number;
compacted: boolean;
error?: string;
}
// === Default Config ===
export const DEFAULT_SESSION_CONFIG: SessionPersistenceConfig = {
enabled: true,
autoSaveIntervalMs: 60000, // 1 minute
maxMessagesBeforeCompact: 100, // Compact after 100 messages
extractMemoriesOnEnd: true,
persistToViking: true,
fallbackToLocal: true,
maxSessionHistory: 50,
sessionTimeoutMs: 1800000, // 30 minutes
};
// === Storage Keys ===
const SESSION_STORAGE_KEY = 'zclaw-sessions';
const CURRENT_SESSION_KEY = 'zclaw-current-session';
// === Session Persistence Service ===
export class SessionPersistenceService {
private config: SessionPersistenceConfig;
private currentSession: SessionState | null = null;
private vikingClient: VikingHttpClient | null = null;
private autoSaveTimer: ReturnType<typeof setInterval> | null = null;
private sessionHistory: SessionSummary[] = [];
constructor(config?: Partial<SessionPersistenceConfig>) {
this.config = { ...DEFAULT_SESSION_CONFIG, ...config };
this.loadSessionHistory();
this.initializeVikingClient();
}
private async initializeVikingClient(): Promise<void> {
try {
this.vikingClient = getVikingClient();
} catch (error) {
console.warn('[SessionPersistence] Viking client initialization failed:', error);
}
}
// === Session Lifecycle ===
/**
* Start a new session.
*/
startSession(agentId: string, metadata?: Record<string, unknown>): SessionState {
// End any existing session first
if (this.currentSession && this.currentSession.status === 'active') {
this.endSession();
}
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
this.currentSession = {
id: sessionId,
agentId,
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
messageCount: 0,
status: 'active',
messages: [],
metadata: metadata || {},
};
this.saveCurrentSession();
this.startAutoSave();
console.log(`[SessionPersistence] Started session: ${sessionId}`);
return this.currentSession;
}
/**
* Add a message to the current session.
*/
addMessage(message: Omit<SessionMessage, 'id' | 'timestamp'>): SessionMessage | null {
if (!this.currentSession || this.currentSession.status !== 'active') {
console.warn('[SessionPersistence] No active session');
return null;
}
const fullMessage: SessionMessage = {
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
...message,
timestamp: new Date().toISOString(),
};
this.currentSession.messages.push(fullMessage);
this.currentSession.messageCount++;
this.currentSession.lastActivityAt = fullMessage.timestamp;
// Check if compaction is needed
if (this.currentSession.messageCount >= this.config.maxMessagesBeforeCompact) {
this.compactSession();
}
return fullMessage;
}
/**
* Pause the current session.
*/
pauseSession(): void {
if (!this.currentSession) return;
this.currentSession.status = 'paused';
this.stopAutoSave();
this.saveCurrentSession();
console.log(`[SessionPersistence] Paused session: ${this.currentSession.id}`);
}
/**
* Resume a paused session.
*/
resumeSession(): SessionState | null {
if (!this.currentSession || this.currentSession.status !== 'paused') {
return this.currentSession;
}
this.currentSession.status = 'active';
this.currentSession.lastActivityAt = new Date().toISOString();
this.startAutoSave();
this.saveCurrentSession();
console.log(`[SessionPersistence] Resumed session: ${this.currentSession.id}`);
return this.currentSession;
}
/**
* End the current session and extract memories.
*/
async endSession(): Promise<PersistenceResult> {
if (!this.currentSession) {
return {
saved: false,
sessionId: '',
messageCount: 0,
extractedMemories: 0,
compacted: false,
error: 'No active session',
};
}
const session = this.currentSession;
session.status = 'ended';
this.stopAutoSave();
let extractedMemories = 0;
let compacted = false;
try {
// Extract memories from the session
if (this.config.extractMemoriesOnEnd && session.messageCount >= 4) {
extractedMemories = await this.extractMemories(session);
}
// Persist to OpenViking if available
if (this.config.persistToViking && this.vikingClient) {
await this.persistToViking(session);
}
// Save to local storage
this.saveToLocalStorage(session);
// Add to history
this.addToHistory(session, extractedMemories, compacted);
console.log(`[SessionPersistence] Ended session: ${session.id}, extracted ${extractedMemories} memories`);
return {
saved: true,
sessionId: session.id,
messageCount: session.messageCount,
extractedMemories,
compacted,
};
} catch (error) {
console.error('[SessionPersistence] Error ending session:', error);
return {
saved: false,
sessionId: session.id,
messageCount: session.messageCount,
extractedMemories: 0,
compacted: false,
error: String(error),
};
} finally {
this.clearCurrentSession();
}
}
// === Memory Extraction ===
private async extractMemories(session: SessionState): Promise<number> {
const extractor = getMemoryExtractor();
// Check if we can auto-extract
const { canProceed } = canAutoExecute('memory_save', 5);
if (!canProceed) {
console.log('[SessionPersistence] Memory extraction requires approval');
return 0;
}
try {
const messages = session.messages.map(m => ({
role: m.role,
content: m.content,
}));
const result = await extractor.extractFromConversation(
messages,
session.agentId,
session.id
);
return result.saved;
} catch (error) {
console.error('[SessionPersistence] Memory extraction failed:', error);
return 0;
}
}
// === Session Compaction ===
private async compactSession(): Promise<void> {
if (!this.currentSession || !this.vikingClient) return;
try {
const messages = this.currentSession.messages.map(m => ({
role: m.role,
content: m.content,
}));
// Use OpenViking to compact the session
const summary = await this.vikingClient.compactSession(messages);
// Keep recent messages, replace older ones with summary
const recentMessages = this.currentSession.messages.slice(-20);
// Create a summary message
const summaryMessage: SessionMessage = {
id: `summary_${Date.now()}`,
role: 'system',
content: `[会话摘要]\n${summary}`,
timestamp: new Date().toISOString(),
metadata: { type: 'compaction-summary' },
};
this.currentSession.messages = [summaryMessage, ...recentMessages];
this.currentSession.messageCount = this.currentSession.messages.length;
console.log(`[SessionPersistence] Compacted session: ${this.currentSession.id}`);
} catch (error) {
console.error('[SessionPersistence] Compaction failed:', error);
}
}
// === Persistence ===
private async persistToViking(session: SessionState): Promise<void> {
if (!this.vikingClient) return;
try {
const sessionContent = session.messages
.map(m => `[${m.role}]: ${m.content}`)
.join('\n\n');
await this.vikingClient.addResource(
`viking://sessions/${session.agentId}/${session.id}`,
sessionContent,
{
metadata: {
startedAt: session.startedAt,
endedAt: new Date().toISOString(),
messageCount: session.messageCount,
agentId: session.agentId,
},
wait: false,
}
);
} catch (error) {
console.error('[SessionPersistence] Viking persistence failed:', error);
if (!this.config.fallbackToLocal) {
throw error;
}
}
}
private saveToLocalStorage(session: SessionState): void {
try {
localStorage.setItem(
`${SESSION_STORAGE_KEY}/${session.id}`,
JSON.stringify(session)
);
} catch (error) {
console.error('[SessionPersistence] Local storage failed:', error);
}
}
private saveCurrentSession(): void {
if (!this.currentSession) return;
try {
localStorage.setItem(CURRENT_SESSION_KEY, JSON.stringify(this.currentSession));
} catch (error) {
console.error('[SessionPersistence] Failed to save current session:', error);
}
}
private loadCurrentSession(): SessionState | null {
try {
const raw = localStorage.getItem(CURRENT_SESSION_KEY);
if (raw) {
return JSON.parse(raw);
}
} catch (error) {
console.error('[SessionPersistence] Failed to load current session:', error);
}
return null;
}
private clearCurrentSession(): void {
this.currentSession = null;
try {
localStorage.removeItem(CURRENT_SESSION_KEY);
} catch {
// Ignore
}
}
// === Auto-save ===
private startAutoSave(): void {
if (this.autoSaveTimer) {
clearInterval(this.autoSaveTimer);
}
this.autoSaveTimer = setInterval(() => {
if (this.currentSession && this.currentSession.status === 'active') {
this.saveCurrentSession();
}
}, this.config.autoSaveIntervalMs);
}
private stopAutoSave(): void {
if (this.autoSaveTimer) {
clearInterval(this.autoSaveTimer);
this.autoSaveTimer = null;
}
}
// === Session History ===
private loadSessionHistory(): void {
try {
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
if (raw) {
this.sessionHistory = JSON.parse(raw);
}
} catch {
this.sessionHistory = [];
}
}
private saveSessionHistory(): void {
try {
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.sessionHistory));
} catch (error) {
console.error('[SessionPersistence] Failed to save session history:', error);
}
}
private addToHistory(session: SessionState, extractedMemories: number, compacted: boolean): void {
const summary: SessionSummary = {
id: session.id,
agentId: session.agentId,
startedAt: session.startedAt,
endedAt: new Date().toISOString(),
messageCount: session.messageCount,
topicsDiscussed: this.extractTopics(session),
memoriesExtracted: extractedMemories,
compacted,
};
this.sessionHistory.unshift(summary);
// Trim to max size
if (this.sessionHistory.length > this.config.maxSessionHistory) {
this.sessionHistory = this.sessionHistory.slice(0, this.config.maxSessionHistory);
}
this.saveSessionHistory();
}
private extractTopics(session: SessionState): string[] {
// Simple topic extraction from user messages
const userMessages = session.messages
.filter(m => m.role === 'user')
.map(m => m.content);
// Look for common patterns
const topics: string[] = [];
const patterns = [
/(?:帮我|请|能否)(.{2,10})/g,
/(?:问题|bug|错误|报错)(.{2,20})/g,
/(?:实现|添加|开发)(.{2,15})/g,
];
for (const msg of userMessages) {
for (const pattern of patterns) {
const matches = msg.matchAll(pattern);
for (const match of matches) {
if (match[1] && match[1].length > 2) {
topics.push(match[1].trim());
}
}
}
}
return [...new Set(topics)].slice(0, 10);
}
// === Public API ===
/**
* Get the current session.
*/
getCurrentSession(): SessionState | null {
return this.currentSession;
}
/**
* Get session history.
*/
getSessionHistory(limit: number = 20): SessionSummary[] {
return this.sessionHistory.slice(0, limit);
}
/**
* Restore a previous session.
*/
restoreSession(sessionId: string): SessionState | null {
try {
const raw = localStorage.getItem(`${SESSION_STORAGE_KEY}/${sessionId}`);
if (raw) {
const session = JSON.parse(raw) as SessionState;
session.status = 'active';
session.lastActivityAt = new Date().toISOString();
this.currentSession = session;
this.startAutoSave();
this.saveCurrentSession();
return session;
}
} catch (error) {
console.error('[SessionPersistence] Failed to restore session:', error);
}
return null;
}
/**
* Delete a session from history.
*/
deleteSession(sessionId: string): boolean {
try {
localStorage.removeItem(`${SESSION_STORAGE_KEY}/${sessionId}`);
this.sessionHistory = this.sessionHistory.filter(s => s.id !== sessionId);
this.saveSessionHistory();
return true;
} catch {
return false;
}
}
/**
* Get configuration.
*/
getConfig(): SessionPersistenceConfig {
return { ...this.config };
}
/**
* Update configuration.
*/
updateConfig(updates: Partial<SessionPersistenceConfig>): void {
this.config = { ...this.config, ...updates };
// Restart auto-save if interval changed
if (updates.autoSaveIntervalMs && this.currentSession?.status === 'active') {
this.stopAutoSave();
this.startAutoSave();
}
}
/**
* Check if session persistence is available.
*/
async isAvailable(): Promise<boolean> {
if (!this.config.enabled) return false;
if (this.config.persistToViking && this.vikingClient) {
return this.vikingClient.isAvailable();
}
return this.config.fallbackToLocal;
}
/**
* Recover from crash - restore last session if valid.
*/
recoverFromCrash(): SessionState | null {
const lastSession = this.loadCurrentSession();
if (!lastSession) return null;
// Check if session timed out
const lastActivity = new Date(lastSession.lastActivityAt).getTime();
const now = Date.now();
if (now - lastActivity > this.config.sessionTimeoutMs) {
console.log('[SessionPersistence] Last session timed out, not recovering');
this.clearCurrentSession();
return null;
}
// Recover the session
lastSession.status = 'active';
lastSession.lastActivityAt = new Date().toISOString();
this.currentSession = lastSession;
this.startAutoSave();
this.saveCurrentSession();
console.log(`[SessionPersistence] Recovered session: ${lastSession.id}`);
return lastSession;
}
}
// === Singleton ===
let _instance: SessionPersistenceService | null = null;
export function getSessionPersistence(config?: Partial<SessionPersistenceConfig>): SessionPersistenceService {
if (!_instance || config) {
_instance = new SessionPersistenceService(config);
}
return _instance;
}
export function resetSessionPersistence(): void {
_instance = null;
}
// === Helper Functions ===
/**
* Quick start a session.
*/
export function startSession(agentId: string, metadata?: Record<string, unknown>): SessionState {
return getSessionPersistence().startSession(agentId, metadata);
}
/**
* Quick add a message.
*/
export function addSessionMessage(message: Omit<SessionMessage, 'id' | 'timestamp'>): SessionMessage | null {
return getSessionPersistence().addMessage(message);
}
/**
* Quick end session.
*/
export async function endCurrentSession(): Promise<PersistenceResult> {
return getSessionPersistence().endSession();
}
/**
* Get current session.
*/
export function getCurrentSession(): SessionState | null {
return getSessionPersistence().getCurrentSession();
}

View File

@@ -0,0 +1,379 @@
/**
* Vector Memory - Semantic search wrapper for L4 self-evolution
*
* Provides vector-based semantic search over agent memories using OpenViking.
* This enables finding conceptually similar memories rather than just keyword matches.
*
* Key capabilities:
* - Semantic search: Find memories by meaning, not just keywords
* - Relevance scoring: Get similarity scores for search results
* - Context-aware: Search at different context levels (L0/L1/L2)
*
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.2
*/
import { getVikingClient, type VikingHttpClient } from './viking-client';
import { getMemoryManager, type MemoryEntry, type MemoryType } from './agent-memory';
// === Types ===
export interface VectorSearchResult {
memory: MemoryEntry;
score: number;
uri: string;
highlights?: string[];
}
export interface VectorSearchOptions {
topK?: number; // Number of results to return (default: 10)
minScore?: number; // Minimum relevance score (default: 0.5)
types?: MemoryType[]; // Filter by memory types
agentId?: string; // Filter by agent
level?: 'L0' | 'L1' | 'L2'; // Context level to search
}
export interface VectorEmbedding {
id: string;
vector: number[];
dimension: number;
model: string;
}
export interface VectorMemoryConfig {
enabled: boolean;
defaultTopK: number;
defaultMinScore: number;
defaultLevel: 'L0' | 'L1' | 'L2';
embeddingModel: string;
cacheEmbeddings: boolean;
}
// === Default Config ===
export const DEFAULT_VECTOR_CONFIG: VectorMemoryConfig = {
enabled: true,
defaultTopK: 10,
defaultMinScore: 0.3,
defaultLevel: 'L1',
embeddingModel: 'text-embedding-ada-002',
cacheEmbeddings: true,
};
// === Vector Memory Service ===
export class VectorMemoryService {
private config: VectorMemoryConfig;
private vikingClient: VikingHttpClient | null = null;
private embeddingCache: Map<string, VectorEmbedding> = new Map();
constructor(config?: Partial<VectorMemoryConfig>) {
this.config = { ...DEFAULT_VECTOR_CONFIG, ...config };
this.initializeClient();
}
private async initializeClient(): Promise<void> {
try {
this.vikingClient = getVikingClient();
} catch (error) {
console.warn('[VectorMemory] Failed to initialize Viking client:', error);
}
}
// === Semantic Search ===
/**
* Perform semantic search over memories.
* Uses OpenViking's built-in vector search capabilities.
*/
async semanticSearch(
query: string,
options?: VectorSearchOptions
): Promise<VectorSearchResult[]> {
if (!this.config.enabled) {
console.warn('[VectorMemory] Semantic search is disabled');
return [];
}
if (!this.vikingClient) {
await this.initializeClient();
if (!this.vikingClient) {
console.warn('[VectorMemory] Viking client not available');
return [];
}
}
try {
const results = await this.vikingClient.find(query, {
limit: options?.topK ?? this.config.defaultTopK,
minScore: options?.minScore ?? this.config.defaultMinScore,
level: options?.level ?? this.config.defaultLevel,
scope: options?.agentId ? `memories/${options.agentId}` : undefined,
});
// Convert FindResult to VectorSearchResult
const searchResults: VectorSearchResult[] = [];
for (const result of results) {
// Convert Viking result to MemoryEntry format
const memory: MemoryEntry = {
id: this.extractMemoryId(result.uri),
agentId: options?.agentId ?? 'unknown',
content: result.content,
type: this.inferMemoryType(result.uri),
importance: Math.round((1 - result.score) * 10), // Invert score to importance
createdAt: new Date().toISOString(),
source: 'auto',
tags: result.metadata?.tags ?? [],
};
searchResults.push({
memory,
score: result.score,
uri: result.uri,
highlights: result.highlights,
});
}
// Apply type filter if specified
if (options?.types && options.types.length > 0) {
return searchResults.filter(r => options.types!.includes(r.memory.type));
}
return searchResults;
} catch (error) {
console.error('[VectorMemory] Semantic search failed:', error);
return [];
}
}
/**
* Find similar memories to a given memory.
*/
async findSimilar(
memoryId: string,
options?: Omit<VectorSearchOptions, 'types'>
): Promise<VectorSearchResult[]> {
// Get the memory content first
const memoryManager = getMemoryManager();
const memories = memoryManager.getByAgent(options?.agentId ?? 'default');
const memory = memories.find(m => m.id === memoryId);
if (!memory) {
console.warn(`[VectorMemory] Memory not found: ${memoryId}`);
return [];
}
// Use the memory content as query for semantic search
const results = await this.semanticSearch(memory.content, {
...options,
topK: (options?.topK ?? 10) + 1, // +1 to account for the memory itself
});
// Filter out the original memory from results
return results.filter(r => r.memory.id !== memoryId);
}
/**
* Find memories related to a topic/concept.
*/
async findByConcept(
concept: string,
options?: VectorSearchOptions
): Promise<VectorSearchResult[]> {
return this.semanticSearch(concept, options);
}
/**
* Cluster memories by semantic similarity.
* Returns groups of related memories.
*/
async clusterMemories(
agentId: string,
clusterCount: number = 5
): Promise<VectorSearchResult[][]> {
const memoryManager = getMemoryManager();
const memories = memoryManager.getByAgent(agentId);
if (memories.length === 0) {
return [];
}
// Simple clustering: use each memory as a seed and find similar ones
const clusters: VectorSearchResult[][] = [];
const usedIds = new Set<string>();
for (const memory of memories) {
if (usedIds.has(memory.id)) continue;
const similar = await this.findSimilar(memory.id, { agentId, topK: clusterCount });
if (similar.length > 0) {
const cluster: VectorSearchResult[] = [
{ memory, score: 1.0, uri: `memory://${memory.id}` },
...similar.filter(r => !usedIds.has(r.memory.id)),
];
cluster.forEach(r => usedIds.add(r.memory.id));
clusters.push(cluster);
if (clusters.length >= clusterCount) break;
}
}
return clusters;
}
// === Embedding Operations ===
/**
* Get or compute embedding for a text.
* Note: OpenViking handles embeddings internally, this is for advanced use.
*/
async getEmbedding(text: string): Promise<VectorEmbedding | null> {
if (!this.config.enabled) return null;
// Check cache first
const cacheKey = this.hashText(text);
if (this.config.cacheEmbeddings && this.embeddingCache.has(cacheKey)) {
return this.embeddingCache.get(cacheKey)!;
}
// OpenViking handles embeddings internally via /api/find
// This method is provided for future extensibility
console.warn('[VectorMemory] Direct embedding computation not available - OpenViking handles this internally');
return null;
}
/**
* Compute similarity between two texts.
*/
async computeSimilarity(text1: string, text2: string): Promise<number> {
if (!this.config.enabled || !this.vikingClient) return 0;
try {
// Use OpenViking to find text1, then check if text2 is in results
const results = await this.vikingClient.find(text1, { limit: 20 });
// If we find text2 in results, return its score
for (const result of results) {
if (result.content.includes(text2) || text2.includes(result.content)) {
return result.score;
}
}
// Otherwise, return 0 (no similarity found)
return 0;
} catch {
return 0;
}
}
// === Utility Methods ===
/**
* Check if vector search is available.
*/
async isAvailable(): Promise<boolean> {
if (!this.config.enabled) return false;
if (!this.vikingClient) {
await this.initializeClient();
}
return this.vikingClient?.isAvailable() ?? false;
}
/**
* Get current configuration.
*/
getConfig(): VectorMemoryConfig {
return { ...this.config };
}
/**
* Update configuration.
*/
updateConfig(updates: Partial<VectorMemoryConfig>): void {
this.config = { ...this.config, ...updates };
}
/**
* Clear embedding cache.
*/
clearCache(): void {
this.embeddingCache.clear();
}
// === Private Helpers ===
private extractMemoryId(uri: string): string {
// Extract memory ID from Viking URI
// Format: memories/agent-id/memory-id or similar
const parts = uri.split('/');
return parts[parts.length - 1] || uri;
}
private inferMemoryType(uri: string): MemoryType {
// Infer memory type from URI or metadata
if (uri.includes('preference')) return 'preference';
if (uri.includes('fact')) return 'fact';
if (uri.includes('task')) return 'task';
if (uri.includes('lesson')) return 'lesson';
return 'fact'; // Default
}
private hashText(text: string): string {
// Simple hash for cache key
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash;
}
return hash.toString(16);
}
}
// === Singleton ===
let _instance: VectorMemoryService | null = null;
export function getVectorMemory(): VectorMemoryService {
if (!_instance) {
_instance = new VectorMemoryService();
}
return _instance;
}
export function resetVectorMemory(): void {
_instance = null;
}
// === Helper Functions ===
/**
* Quick semantic search helper.
*/
export async function semanticSearch(
query: string,
options?: VectorSearchOptions
): Promise<VectorSearchResult[]> {
return getVectorMemory().semanticSearch(query, options);
}
/**
* Find similar memories helper.
*/
export async function findSimilarMemories(
memoryId: string,
agentId?: string
): Promise<VectorSearchResult[]> {
return getVectorMemory().findSimilar(memoryId, { agentId });
}
/**
* Check if vector search is available.
*/
export async function isVectorSearchAvailable(): Promise<boolean> {
return getVectorMemory().isAvailable();
}

View File

@@ -327,3 +327,26 @@ export class VikingError extends Error {
this.name = 'VikingError';
}
}
// === Singleton ===
let _instance: VikingHttpClient | null = null;
/**
* Get the singleton VikingHttpClient instance.
* Uses default configuration (localhost:1933).
*/
export function getVikingClient(baseUrl?: string): VikingHttpClient {
if (!_instance) {
_instance = new VikingHttpClient(baseUrl);
}
return _instance;
}
/**
* Reset the singleton instance.
* Useful for testing or reconfiguration.
*/
export function resetVikingClient(): void {
_instance = null;
}