- Add viking_server.rs (Rust) for managing local OpenViking server process - Add viking-server-manager.ts (TypeScript) for server control from UI - Update VikingAdapter to support 'local' mode with auto-start capability - Update documentation for local deployment mode Key features: - Auto-start local server when needed - All data stays in ~/.openviking/ (privacy-first) - Server listens only on 127.0.0.1 - Graceful fallback to remote/localStorage modes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
735 lines
20 KiB
TypeScript
735 lines
20 KiB
TypeScript
/**
|
|
* Viking Adapter - ZCLAW ↔ OpenViking Integration Layer
|
|
*
|
|
* Maps ZCLAW agent concepts (memories, identity, skills) to OpenViking's
|
|
* viking:// URI namespace. Provides high-level operations for:
|
|
* - User memory management (preferences, facts, history)
|
|
* - Agent memory management (lessons, patterns, tool tips)
|
|
* - L0/L1/L2 layered context building (token-efficient)
|
|
* - Session memory extraction (auto-learning)
|
|
* - Identity file synchronization
|
|
* - Retrieval trace capture (debuggability)
|
|
*
|
|
* Supports three modes:
|
|
* - local: Manages a local OpenViking server (privacy-first, data stays local)
|
|
* - sidecar: Uses OpenViking CLI via Tauri commands (direct CLI integration)
|
|
* - remote: Uses OpenViking HTTP Server (connects to external server)
|
|
*
|
|
* For privacy-conscious users, use 'local' mode which ensures all data
|
|
* stays on the local machine in ~/.openviking/
|
|
*/
|
|
|
|
import {
|
|
VikingHttpClient,
|
|
type FindResult,
|
|
type RetrievalTrace,
|
|
type ExtractedMemory,
|
|
type SessionExtractionResult,
|
|
type ContextLevel,
|
|
type VikingEntry,
|
|
type VikingTreeNode,
|
|
} from './viking-client';
|
|
import {
|
|
getVikingServerManager,
|
|
type VikingServerStatus,
|
|
} from './viking-server-manager';
|
|
|
|
// Tauri invoke import (safe to import even if not in Tauri context)
|
|
let invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
|
|
|
|
try {
|
|
// Dynamic import for Tauri API
|
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
invoke = require('@tauri-apps/api/core').invoke;
|
|
} catch {
|
|
// Not in Tauri context, invoke will be null
|
|
console.log('[VikingAdapter] Not in Tauri context, sidecar mode unavailable');
|
|
}
|
|
|
|
// === Types ===
|
|
|
|
export interface MemoryResult {
|
|
uri: string;
|
|
content: string;
|
|
score: number;
|
|
level: ContextLevel;
|
|
category: string;
|
|
tags?: string[];
|
|
}
|
|
|
|
export interface EnhancedContext {
|
|
systemPromptAddition: string;
|
|
memories: MemoryResult[];
|
|
totalTokens: number;
|
|
tokensByLevel: { L0: number; L1: number; L2: number };
|
|
trace?: RetrievalTrace;
|
|
}
|
|
|
|
export interface MemorySaveResult {
|
|
uri: string;
|
|
status: string;
|
|
}
|
|
|
|
export interface ExtractionResult {
|
|
saved: number;
|
|
userMemories: number;
|
|
agentMemories: number;
|
|
details: ExtractedMemory[];
|
|
}
|
|
|
|
export interface IdentityFile {
|
|
name: string;
|
|
content: string;
|
|
lastModified?: string;
|
|
}
|
|
|
|
export interface IdentityChangeProposal {
|
|
file: string;
|
|
currentContent: string;
|
|
suggestedContent: string;
|
|
reason: string;
|
|
timestamp: string;
|
|
}
|
|
|
|
export interface VikingAdapterConfig {
|
|
serverUrl: string;
|
|
defaultAgentId: string;
|
|
maxContextTokens: number;
|
|
l0Limit: number;
|
|
l1Limit: number;
|
|
minRelevanceScore: number;
|
|
enableTrace: boolean;
|
|
mode?: VikingMode;
|
|
}
|
|
|
|
const DEFAULT_CONFIG: VikingAdapterConfig = {
|
|
serverUrl: 'http://localhost:1933',
|
|
defaultAgentId: 'zclaw-main',
|
|
maxContextTokens: 8000,
|
|
l0Limit: 30,
|
|
l1Limit: 15,
|
|
minRelevanceScore: 0.5,
|
|
enableTrace: true,
|
|
};
|
|
|
|
// === URI Helpers ===
|
|
|
|
const VIKING_NS = {
|
|
userMemories: 'viking://user/memories',
|
|
userPreferences: 'viking://user/memories/preferences',
|
|
userFacts: 'viking://user/memories/facts',
|
|
userHistory: 'viking://user/memories/history',
|
|
agentBase: (agentId: string) => `viking://agent/${agentId}`,
|
|
agentIdentity: (agentId: string) => `viking://agent/${agentId}/identity`,
|
|
agentMemories: (agentId: string) => `viking://agent/${agentId}/memories`,
|
|
agentLessons: (agentId: string) => `viking://agent/${agentId}/memories/lessons_learned`,
|
|
agentPatterns: (agentId: string) => `viking://agent/${agentId}/memories/task_patterns`,
|
|
agentToolTips: (agentId: string) => `viking://agent/${agentId}/memories/tool_tips`,
|
|
agentSkills: (agentId: string) => `viking://agent/${agentId}/skills`,
|
|
sharedKnowledge: 'viking://agent/shared/common_knowledge',
|
|
resources: 'viking://resources',
|
|
} as const;
|
|
|
|
// === Rough Token Estimator ===
|
|
|
|
function estimateTokens(text: string): number {
|
|
// ~1.5 tokens per CJK character, ~0.75 tokens per English word
|
|
const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
|
const otherChars = text.length - cjkChars;
|
|
return Math.ceil(cjkChars * 1.5 + otherChars * 0.4);
|
|
}
|
|
|
|
// === Mode Type ===
|
|
|
|
export type VikingMode = 'local' | 'sidecar' | 'remote' | 'auto';
|
|
|
|
// === Adapter Implementation ===
|
|
|
|
export class VikingAdapter {
|
|
private client: VikingHttpClient;
|
|
private config: VikingAdapterConfig;
|
|
private lastTrace: RetrievalTrace | null = null;
|
|
private mode: VikingMode;
|
|
private resolvedMode: 'local' | 'sidecar' | 'remote' | null = null;
|
|
private serverManager = getVikingServerManager();
|
|
|
|
constructor(config?: Partial<VikingAdapterConfig>) {
|
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
|
this.client = new VikingHttpClient(this.config.serverUrl);
|
|
this.mode = config?.mode ?? 'auto';
|
|
}
|
|
|
|
// === Mode Detection ===
|
|
|
|
private async detectMode(): Promise<'local' | 'sidecar' | 'remote'> {
|
|
if (this.resolvedMode) {
|
|
return this.resolvedMode;
|
|
}
|
|
|
|
if (this.mode === 'local') {
|
|
this.resolvedMode = 'local';
|
|
return 'local';
|
|
}
|
|
|
|
if (this.mode === 'sidecar') {
|
|
this.resolvedMode = 'sidecar';
|
|
return 'sidecar';
|
|
}
|
|
|
|
if (this.mode === 'remote') {
|
|
this.resolvedMode = 'remote';
|
|
return 'remote';
|
|
}
|
|
|
|
// Auto mode: try local server first (privacy-first), then sidecar, then remote
|
|
// 1. Check if local server is already running or can be started
|
|
if (invoke) {
|
|
try {
|
|
const status = await this.serverManager.getStatus();
|
|
if (status.running) {
|
|
console.log('[VikingAdapter] Using local mode (OpenViking local server already running)');
|
|
this.resolvedMode = 'local';
|
|
return 'local';
|
|
}
|
|
|
|
// Try to start local server
|
|
const started = await this.serverManager.ensureRunning();
|
|
if (started) {
|
|
console.log('[VikingAdapter] Using local mode (OpenViking local server started)');
|
|
this.resolvedMode = 'local';
|
|
return 'local';
|
|
}
|
|
} catch {
|
|
console.log('[VikingAdapter] Local server not available, trying sidecar');
|
|
}
|
|
}
|
|
|
|
// 2. Try sidecar mode
|
|
if (invoke) {
|
|
try {
|
|
const status = await invoke('viking_status') as { available: boolean };
|
|
if (status.available) {
|
|
console.log('[VikingAdapter] Using sidecar mode (OpenViking CLI)');
|
|
this.resolvedMode = 'sidecar';
|
|
return 'sidecar';
|
|
}
|
|
} catch {
|
|
console.log('[VikingAdapter] Sidecar mode not available, trying remote');
|
|
}
|
|
}
|
|
|
|
// 3. Try remote mode
|
|
if (await this.client.isAvailable()) {
|
|
console.log('[VikingAdapter] Using remote mode (OpenViking Server)');
|
|
this.resolvedMode = 'remote';
|
|
return 'remote';
|
|
}
|
|
|
|
console.warn('[VikingAdapter] No Viking backend available');
|
|
return 'remote'; // Default fallback
|
|
}
|
|
|
|
getMode(): 'local' | 'sidecar' | 'remote' | null {
|
|
return this.resolvedMode;
|
|
}
|
|
|
|
// === Connection ===
|
|
|
|
async isConnected(): Promise<boolean> {
|
|
const mode = await this.detectMode();
|
|
|
|
if (mode === 'local') {
|
|
const status = await this.serverManager.getStatus();
|
|
return status.running;
|
|
}
|
|
|
|
if (mode === 'sidecar') {
|
|
try {
|
|
if (!invoke) return false;
|
|
const status = await invoke('viking_status') as { available: boolean };
|
|
return status.available;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return this.client.isAvailable();
|
|
}
|
|
|
|
// === Server Management (for local mode) ===
|
|
|
|
/**
|
|
* Get the local server status (for local mode)
|
|
*/
|
|
async getLocalServerStatus(): Promise<VikingServerStatus> {
|
|
return this.serverManager.getStatus();
|
|
}
|
|
|
|
/**
|
|
* Start the local server (for local mode)
|
|
*/
|
|
async startLocalServer(): Promise<VikingServerStatus> {
|
|
return this.serverManager.start();
|
|
}
|
|
|
|
/**
|
|
* Stop the local server (for local mode)
|
|
*/
|
|
async stopLocalServer(): Promise<void> {
|
|
return this.serverManager.stop();
|
|
}
|
|
|
|
getLastTrace(): RetrievalTrace | null {
|
|
return this.lastTrace;
|
|
}
|
|
|
|
// === User Memory Operations ===
|
|
|
|
async saveUserPreference(
|
|
key: string,
|
|
value: string
|
|
): Promise<MemorySaveResult> {
|
|
const uri = `${VIKING_NS.userPreferences}/${sanitizeKey(key)}`;
|
|
return this.client.addResource(uri, value, {
|
|
metadata: { type: 'preference', key, updated_at: new Date().toISOString() },
|
|
wait: true,
|
|
});
|
|
}
|
|
|
|
async saveUserFact(
|
|
category: string,
|
|
content: string,
|
|
tags?: string[]
|
|
): Promise<MemorySaveResult> {
|
|
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const uri = `${VIKING_NS.userFacts}/${sanitizeKey(category)}/${id}`;
|
|
return this.client.addResource(uri, content, {
|
|
metadata: {
|
|
type: 'fact',
|
|
category,
|
|
tags: (tags || []).join(','),
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
wait: true,
|
|
});
|
|
}
|
|
|
|
async searchUserMemories(
|
|
query: string,
|
|
limit: number = 10
|
|
): Promise<MemoryResult[]> {
|
|
const results = await this.client.find(query, {
|
|
scope: VIKING_NS.userMemories,
|
|
limit,
|
|
level: 'L1',
|
|
minScore: this.config.minRelevanceScore,
|
|
});
|
|
return results.map(toMemoryResult);
|
|
}
|
|
|
|
async getUserPreferences(): Promise<VikingEntry[]> {
|
|
try {
|
|
return await this.client.ls(VIKING_NS.userPreferences);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
// === Agent Memory Operations ===
|
|
|
|
async saveAgentLesson(
|
|
agentId: string,
|
|
lesson: string,
|
|
tags?: string[]
|
|
): Promise<MemorySaveResult> {
|
|
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const uri = `${VIKING_NS.agentLessons(agentId)}/${id}`;
|
|
return this.client.addResource(uri, lesson, {
|
|
metadata: {
|
|
type: 'lesson',
|
|
tags: (tags || []).join(','),
|
|
agent_id: agentId,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
wait: true,
|
|
});
|
|
}
|
|
|
|
async saveAgentPattern(
|
|
agentId: string,
|
|
pattern: string,
|
|
tags?: string[]
|
|
): Promise<MemorySaveResult> {
|
|
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
|
const uri = `${VIKING_NS.agentPatterns(agentId)}/${id}`;
|
|
return this.client.addResource(uri, pattern, {
|
|
metadata: {
|
|
type: 'pattern',
|
|
tags: (tags || []).join(','),
|
|
agent_id: agentId,
|
|
created_at: new Date().toISOString(),
|
|
},
|
|
wait: true,
|
|
});
|
|
}
|
|
|
|
async saveAgentToolTip(
|
|
agentId: string,
|
|
tip: string,
|
|
toolName: string
|
|
): Promise<MemorySaveResult> {
|
|
const uri = `${VIKING_NS.agentToolTips(agentId)}/${sanitizeKey(toolName)}`;
|
|
return this.client.addResource(uri, tip, {
|
|
metadata: {
|
|
type: 'tool_tip',
|
|
tool: toolName,
|
|
agent_id: agentId,
|
|
updated_at: new Date().toISOString(),
|
|
},
|
|
wait: true,
|
|
});
|
|
}
|
|
|
|
async searchAgentMemories(
|
|
agentId: string,
|
|
query: string,
|
|
limit: number = 10
|
|
): Promise<MemoryResult[]> {
|
|
const results = await this.client.find(query, {
|
|
scope: VIKING_NS.agentMemories(agentId),
|
|
limit,
|
|
level: 'L1',
|
|
minScore: this.config.minRelevanceScore,
|
|
});
|
|
return results.map(toMemoryResult);
|
|
}
|
|
|
|
// === Identity File Management ===
|
|
|
|
async syncIdentityToViking(
|
|
agentId: string,
|
|
fileName: string,
|
|
content: string
|
|
): Promise<void> {
|
|
const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`;
|
|
await this.client.addResource(uri, content, {
|
|
metadata: {
|
|
type: 'identity',
|
|
file: fileName,
|
|
agent_id: agentId,
|
|
synced_at: new Date().toISOString(),
|
|
},
|
|
wait: true,
|
|
});
|
|
}
|
|
|
|
async getIdentityFromViking(
|
|
agentId: string,
|
|
fileName: string
|
|
): Promise<string> {
|
|
const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`;
|
|
return this.client.readContent(uri, 'L2');
|
|
}
|
|
|
|
async proposeIdentityChange(
|
|
agentId: string,
|
|
proposal: IdentityChangeProposal
|
|
): Promise<MemorySaveResult> {
|
|
const id = `${Date.now()}`;
|
|
const uri = `${VIKING_NS.agentIdentity(agentId)}/changelog/${id}`;
|
|
const content = [
|
|
`# Identity Change Proposal`,
|
|
`**File**: ${proposal.file}`,
|
|
`**Reason**: ${proposal.reason}`,
|
|
`**Timestamp**: ${proposal.timestamp}`,
|
|
'',
|
|
'## Current Content',
|
|
'```',
|
|
proposal.currentContent,
|
|
'```',
|
|
'',
|
|
'## Suggested Content',
|
|
'```',
|
|
proposal.suggestedContent,
|
|
'```',
|
|
].join('\n');
|
|
|
|
return this.client.addResource(uri, content, {
|
|
metadata: {
|
|
type: 'identity_change_proposal',
|
|
file: proposal.file,
|
|
status: 'pending',
|
|
agent_id: agentId,
|
|
},
|
|
wait: true,
|
|
});
|
|
}
|
|
|
|
// === Core: Context Building (L0/L1/L2 layered loading) ===
|
|
|
|
async buildEnhancedContext(
|
|
userMessage: string,
|
|
agentId: string,
|
|
options?: { maxTokens?: number; includeTrace?: boolean }
|
|
): Promise<EnhancedContext> {
|
|
const maxTokens = options?.maxTokens ?? this.config.maxContextTokens;
|
|
const includeTrace = options?.includeTrace ?? this.config.enableTrace;
|
|
|
|
const tokensByLevel = { L0: 0, L1: 0, L2: 0 };
|
|
|
|
// Step 1: L0 fast scan across user + agent memories
|
|
const [userL0, agentL0] = await Promise.all([
|
|
this.client.find(userMessage, {
|
|
scope: VIKING_NS.userMemories,
|
|
level: 'L0',
|
|
limit: this.config.l0Limit,
|
|
}).catch(() => [] as FindResult[]),
|
|
this.client.find(userMessage, {
|
|
scope: VIKING_NS.agentMemories(agentId),
|
|
level: 'L0',
|
|
limit: this.config.l0Limit,
|
|
}).catch(() => [] as FindResult[]),
|
|
]);
|
|
|
|
const allL0 = [...userL0, ...agentL0];
|
|
for (const r of allL0) {
|
|
tokensByLevel.L0 += estimateTokens(r.content);
|
|
}
|
|
|
|
// Step 2: Filter high-relevance items, load L1
|
|
const relevant = allL0
|
|
.filter(r => r.score >= this.config.minRelevanceScore)
|
|
.sort((a, b) => b.score - a.score)
|
|
.slice(0, this.config.l1Limit);
|
|
|
|
const l1Results: MemoryResult[] = [];
|
|
let tokenBudget = maxTokens;
|
|
|
|
for (const item of relevant) {
|
|
try {
|
|
const l1Content = await this.client.readContent(item.uri, 'L1');
|
|
const tokens = estimateTokens(l1Content);
|
|
|
|
if (tokenBudget - tokens < 500) break; // Keep 500 token reserve
|
|
|
|
l1Results.push({
|
|
uri: item.uri,
|
|
content: l1Content,
|
|
score: item.score,
|
|
level: 'L1',
|
|
category: extractCategory(item.uri),
|
|
});
|
|
|
|
tokenBudget -= tokens;
|
|
tokensByLevel.L1 += tokens;
|
|
} catch {
|
|
// Skip items that fail to load
|
|
}
|
|
}
|
|
|
|
// Step 3: Build retrieval trace (if enabled)
|
|
let trace: RetrievalTrace | undefined;
|
|
if (includeTrace) {
|
|
trace = {
|
|
query: userMessage,
|
|
steps: allL0.map(r => ({
|
|
uri: r.uri,
|
|
score: r.score,
|
|
action: r.score >= this.config.minRelevanceScore ? 'entered' as const : 'skipped' as const,
|
|
level: 'L0' as ContextLevel,
|
|
})),
|
|
totalTokensUsed: maxTokens - tokenBudget,
|
|
tokensByLevel,
|
|
duration: 0, // filled by caller if timing
|
|
};
|
|
this.lastTrace = trace;
|
|
}
|
|
|
|
// Step 4: Format as system prompt addition
|
|
const systemPromptAddition = formatMemoriesForPrompt(l1Results);
|
|
|
|
return {
|
|
systemPromptAddition,
|
|
memories: l1Results,
|
|
totalTokens: maxTokens - tokenBudget,
|
|
tokensByLevel,
|
|
trace,
|
|
};
|
|
}
|
|
|
|
// === Session Memory Extraction ===
|
|
|
|
async extractAndSaveMemories(
|
|
messages: Array<{ role: string; content: string }>,
|
|
agentId: string,
|
|
_conversationId?: string
|
|
): Promise<ExtractionResult> {
|
|
const sessionContent = messages
|
|
.map(m => `[${m.role}]: ${m.content}`)
|
|
.join('\n\n');
|
|
|
|
let extraction: SessionExtractionResult;
|
|
try {
|
|
extraction = await this.client.extractMemories(sessionContent, agentId);
|
|
} catch (err) {
|
|
// If OpenViking extraction API is not available, use fallback
|
|
console.warn('[VikingAdapter] Session extraction failed, using fallback:', err);
|
|
return { saved: 0, userMemories: 0, agentMemories: 0, details: [] };
|
|
}
|
|
|
|
let userCount = 0;
|
|
let agentCount = 0;
|
|
|
|
for (const memory of extraction.memories) {
|
|
try {
|
|
if (memory.category === 'user_preference') {
|
|
const key = memory.tags[0] || `pref_${Date.now()}`;
|
|
await this.saveUserPreference(key, memory.content);
|
|
userCount++;
|
|
} else if (memory.category === 'user_fact') {
|
|
const category = memory.tags[0] || 'general';
|
|
await this.saveUserFact(category, memory.content, memory.tags);
|
|
userCount++;
|
|
} else if (memory.category === 'agent_lesson') {
|
|
await this.saveAgentLesson(agentId, memory.content, memory.tags);
|
|
agentCount++;
|
|
} else if (memory.category === 'agent_pattern') {
|
|
await this.saveAgentPattern(agentId, memory.content, memory.tags);
|
|
agentCount++;
|
|
}
|
|
} catch (err) {
|
|
console.warn('[VikingAdapter] Failed to save memory:', memory.suggestedUri, err);
|
|
}
|
|
}
|
|
|
|
return {
|
|
saved: userCount + agentCount,
|
|
userMemories: userCount,
|
|
agentMemories: agentCount,
|
|
details: extraction.memories,
|
|
};
|
|
}
|
|
|
|
// === Memory Browsing ===
|
|
|
|
async browseMemories(
|
|
path: string = 'viking://'
|
|
): Promise<VikingEntry[]> {
|
|
try {
|
|
return await this.client.ls(path);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async getMemoryTree(
|
|
agentId: string,
|
|
depth: number = 2
|
|
): Promise<VikingTreeNode | null> {
|
|
try {
|
|
return await this.client.tree(VIKING_NS.agentBase(agentId), depth);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async deleteMemory(uri: string): Promise<void> {
|
|
await this.client.removeResource(uri);
|
|
}
|
|
|
|
// === Memory Statistics ===
|
|
|
|
async getMemoryStats(agentId: string): Promise<{
|
|
totalEntries: number;
|
|
userMemories: number;
|
|
agentMemories: number;
|
|
categories: Record<string, number>;
|
|
}> {
|
|
const [userEntries, agentEntries] = await Promise.all([
|
|
this.client.ls(VIKING_NS.userMemories).catch(() => []),
|
|
this.client.ls(VIKING_NS.agentMemories(agentId)).catch(() => []),
|
|
]);
|
|
|
|
const categories: Record<string, number> = {};
|
|
for (const entry of [...userEntries, ...agentEntries]) {
|
|
const cat = extractCategory(entry.uri);
|
|
categories[cat] = (categories[cat] || 0) + 1;
|
|
}
|
|
|
|
return {
|
|
totalEntries: userEntries.length + agentEntries.length,
|
|
userMemories: userEntries.length,
|
|
agentMemories: agentEntries.length,
|
|
categories,
|
|
};
|
|
}
|
|
}
|
|
|
|
// === Utility Functions ===
|
|
|
|
function sanitizeKey(key: string): string {
|
|
return key
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\u4e00-\u9fff_-]/g, '_')
|
|
.replace(/_+/g, '_')
|
|
.replace(/^_|_$/g, '');
|
|
}
|
|
|
|
function extractCategory(uri: string): string {
|
|
const parts = uri.replace('viking://', '').split('/');
|
|
// Return the 3rd segment as category (e.g., "preferences" from viking://user/memories/preferences/...)
|
|
return parts[2] || parts[1] || 'unknown';
|
|
}
|
|
|
|
function toMemoryResult(result: FindResult): MemoryResult {
|
|
return {
|
|
uri: result.uri,
|
|
content: result.content,
|
|
score: result.score,
|
|
level: result.level,
|
|
category: extractCategory(result.uri),
|
|
};
|
|
}
|
|
|
|
function formatMemoriesForPrompt(memories: MemoryResult[]): string {
|
|
if (memories.length === 0) return '';
|
|
|
|
const userMemories = memories.filter(m => m.uri.startsWith('viking://user/'));
|
|
const agentMemories = memories.filter(m => m.uri.startsWith('viking://agent/'));
|
|
|
|
const sections: string[] = [];
|
|
|
|
if (userMemories.length > 0) {
|
|
sections.push('## 用户记忆');
|
|
for (const m of userMemories) {
|
|
sections.push(`- [${m.category}] ${m.content}`);
|
|
}
|
|
}
|
|
|
|
if (agentMemories.length > 0) {
|
|
sections.push('## Agent 经验');
|
|
for (const m of agentMemories) {
|
|
sections.push(`- [${m.category}] ${m.content}`);
|
|
}
|
|
}
|
|
|
|
return sections.join('\n');
|
|
}
|
|
|
|
// === Singleton factory ===
|
|
|
|
let _instance: VikingAdapter | null = null;
|
|
|
|
export function getVikingAdapter(config?: Partial<VikingAdapterConfig>): VikingAdapter {
|
|
if (!_instance || config) {
|
|
_instance = new VikingAdapter(config);
|
|
}
|
|
return _instance;
|
|
}
|
|
|
|
export function resetVikingAdapter(): void {
|
|
_instance = null;
|
|
}
|
|
|
|
export { VIKING_NS };
|