🐛 fix: fix memory search context (#11393)

fix memory search
This commit is contained in:
Arvin Xu
2026-01-10 20:01:52 +08:00
committed by GitHub
parent 83f3ceebad
commit 9f51a4ca0d
8 changed files with 435 additions and 41 deletions

View File

@@ -8,7 +8,8 @@
},
"main": "./src/index.ts",
"dependencies": {
"@lobechat/memory-user-memory": "workspace:*"
"@lobechat/memory-user-memory": "workspace:*",
"@lobechat/prompts": "workspace:*"
},
"devDependencies": {
"@lobechat/types": "workspace:*"

View File

@@ -6,6 +6,7 @@ import type {
RemoveIdentityActionSchema,
UpdateIdentityActionSchema,
} from '@lobechat/memory-user-memory/schemas';
import { formatMemorySearchResults } from '@lobechat/prompts';
import { BaseExecutor, type BuiltinToolResult, SearchMemoryParams } from '@lobechat/types';
import type { z } from 'zod';
@@ -14,35 +15,6 @@ import { userMemoryService } from '@/services/userMemory';
import { MemoryIdentifier } from '../manifest';
import { MemoryApiName } from '../types';
/**
* Format search results into human-readable summary
*/
const formatSearchResultsSummary = (result: {
contexts: unknown[];
experiences: unknown[];
preferences: unknown[];
}): string => {
const total = result.contexts.length + result.experiences.length + result.preferences.length;
if (total === 0) {
return '🔍 No memories found matching the query.';
}
const parts: string[] = [`🔍 Found ${total} memories:`];
if (result.contexts.length > 0) {
parts.push(`- ${result.contexts.length} context memories`);
}
if (result.experiences.length > 0) {
parts.push(`- ${result.experiences.length} experience memories`);
}
if (result.preferences.length > 0) {
parts.push(`- ${result.preferences.length} preference memories`);
}
return parts.join('\n');
};
/**
* Memory Tool Executor
*
@@ -62,7 +34,7 @@ class MemoryExecutor extends BaseExecutor<typeof MemoryApiName> {
const result = await userMemoryService.searchMemory(params);
return {
content: formatSearchResultsSummary(result),
content: formatMemorySearchResults({ query: params.query, results: result }),
state: result,
success: true,
};

View File

@@ -0,0 +1,65 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`formatMemorySearchResults > should format context memories with full content 1`] = `
"<memories query="project" total="1">
<contexts count="1">
<context id="ctx-1" title="Web App Project" urgency=7 impact=8 type="project" status="in-progress">
Building a new web application
<subjects>John (person)</subjects>
<objects>React (application)</objects>
</context>
</contexts>
</memories>"
`;
exports[`formatMemorySearchResults > should format experience memories with full content 1`] = `
"<memories query="debugging" total="1">
<experiences count="1">
<experience id="exp-1" type="lesson" confidence=9>
<situation>Debugging complex state issues</situation>
<keyLearning>Breakpoints save time in complex debugging</keyLearning>
</experience>
</experiences>
</memories>"
`;
exports[`formatMemorySearchResults > should format mixed results with all memory types 1`] = `
"<memories query="work" total="3">
<contexts count="1">
<context id="ctx-1" title="Context Title">
Context description
</context>
</contexts>
<experiences count="1">
<experience id="exp-1">
<situation>Situation</situation>
<keyLearning>Key learning</keyLearning>
</experience>
</experiences>
<preferences count="1">
<preference id="pref-1">Directive</preference>
</preferences>
</memories>"
`;
exports[`formatMemorySearchResults > should format preference memories with full content 1`] = `
"<memories query="code style" total="1">
<preferences count="1">
<preference id="pref-1" type="coding-standard" priority=10>Always use TypeScript strict mode</preference>
</preferences>
</memories>"
`;
exports[`formatMemorySearchResults > should handle null and undefined values gracefully 1`] = `
"<memories query="test" total="1">
<contexts count="1">
<context id="ctx-1"></context>
</contexts>
</memories>"
`;
exports[`formatMemorySearchResults > should return empty results message when no memories found 1`] = `
"<memories query="test query">
<status>No memories found matching the query.</status>
</memories>"
`;

View File

@@ -0,0 +1,200 @@
import { UserMemoryContextObjectType, UserMemoryContextSubjectType } from '@lobechat/types';
import { describe, expect, it } from 'vitest';
import { formatMemorySearchResults } from './formatSearchResults';
describe('formatMemorySearchResults', () => {
it('should return empty results message when no memories found', () => {
const result = formatMemorySearchResults({
query: 'test query',
results: {
contexts: [],
experiences: [],
preferences: [],
},
});
expect(result).toMatchSnapshot();
});
it('should format context memories with full content', () => {
const result = formatMemorySearchResults({
query: 'project',
results: {
contexts: [
{
accessedAt: new Date('2024-01-01'),
associatedObjects: [{ name: 'React', type: UserMemoryContextObjectType.Application }],
associatedSubjects: [{ name: 'John', type: UserMemoryContextSubjectType.Person }],
createdAt: new Date('2024-01-01'),
currentStatus: 'in-progress',
description: 'Building a new web application',
id: 'ctx-1',
metadata: null,
scoreImpact: 8,
scoreUrgency: 7,
tags: ['frontend', 'react'],
title: 'Web App Project',
type: 'project',
updatedAt: new Date('2024-01-01'),
userMemoryIds: null,
},
],
experiences: [],
preferences: [],
},
});
expect(result).toMatchSnapshot();
});
it('should format experience memories with full content', () => {
const result = formatMemorySearchResults({
query: 'debugging',
results: {
contexts: [],
experiences: [
{
accessedAt: new Date('2024-01-01'),
action: 'Used breakpoints instead of console.log',
createdAt: new Date('2024-01-01'),
id: 'exp-1',
keyLearning: 'Breakpoints save time in complex debugging',
metadata: null,
possibleOutcome: 'Faster debugging sessions',
reasoning: 'Console logs clutter the code',
scoreConfidence: 9,
situation: 'Debugging complex state issues',
tags: ['debugging', 'best-practice'],
type: 'lesson',
updatedAt: new Date('2024-01-01'),
userMemoryId: null,
},
],
preferences: [],
},
});
expect(result).toMatchSnapshot();
});
it('should format preference memories with full content', () => {
const result = formatMemorySearchResults({
query: 'code style',
results: {
contexts: [],
experiences: [],
preferences: [
{
accessedAt: new Date('2024-01-01'),
conclusionDirectives: 'Always use TypeScript strict mode',
createdAt: new Date('2024-01-01'),
id: 'pref-1',
metadata: null,
scorePriority: 10,
suggestions: 'Consider adding eslint rules',
tags: ['typescript', 'code-quality'],
type: 'coding-standard',
updatedAt: new Date('2024-01-01'),
userMemoryId: null,
},
],
},
});
expect(result).toMatchSnapshot();
});
it('should format mixed results with all memory types', () => {
const result = formatMemorySearchResults({
query: 'work',
results: {
contexts: [
{
accessedAt: new Date('2024-01-01'),
associatedObjects: null,
associatedSubjects: null,
createdAt: new Date('2024-01-01'),
currentStatus: null,
description: 'Context description',
id: 'ctx-1',
metadata: null,
scoreImpact: null,
scoreUrgency: null,
tags: null,
title: 'Context Title',
type: null,
updatedAt: new Date('2024-01-01'),
userMemoryIds: null,
},
],
experiences: [
{
accessedAt: new Date('2024-01-01'),
action: null,
createdAt: new Date('2024-01-01'),
id: 'exp-1',
keyLearning: 'Key learning',
metadata: null,
possibleOutcome: null,
reasoning: null,
scoreConfidence: null,
situation: 'Situation',
tags: null,
type: null,
updatedAt: new Date('2024-01-01'),
userMemoryId: null,
},
],
preferences: [
{
accessedAt: new Date('2024-01-01'),
conclusionDirectives: 'Directive',
createdAt: new Date('2024-01-01'),
id: 'pref-1',
metadata: null,
scorePriority: null,
suggestions: null,
tags: null,
type: null,
updatedAt: new Date('2024-01-01'),
userMemoryId: null,
},
],
},
});
expect(result).toMatchSnapshot();
});
it('should handle null and undefined values gracefully', () => {
const result = formatMemorySearchResults({
query: 'test',
results: {
contexts: [
{
accessedAt: new Date('2024-01-01'),
associatedObjects: null,
associatedSubjects: null,
createdAt: new Date('2024-01-01'),
currentStatus: null,
description: null,
id: 'ctx-1',
metadata: null,
scoreImpact: null,
scoreUrgency: null,
tags: null,
title: null,
type: null,
updatedAt: new Date('2024-01-01'),
userMemoryIds: null,
},
],
experiences: [],
preferences: [],
},
});
expect(result).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,164 @@
import type { SearchMemoryResult } from '@lobechat/types';
/**
* Search result item interfaces matching the SearchMemoryResult type
*/
type ContextResult = SearchMemoryResult['contexts'][number];
type ExperienceResult = SearchMemoryResult['experiences'][number];
type PreferenceResult = SearchMemoryResult['preferences'][number];
/**
* Format a single context memory item for search results
* Format: attributes for metadata, description as text content
*/
const formatContextResult = (item: ContextResult): string => {
const attrs: string[] = [`id="${item.id}"`];
if (item.title) {
attrs.push(`title="${item.title}"`);
}
if (item.scoreUrgency !== null && item.scoreUrgency !== undefined) {
attrs.push(`urgency=${item.scoreUrgency}`);
}
if (item.scoreImpact !== null && item.scoreImpact !== undefined) {
attrs.push(`impact=${item.scoreImpact}`);
}
if (item.type) {
attrs.push(`type="${item.type}"`);
}
if (item.currentStatus) {
attrs.push(`status="${item.currentStatus}"`);
}
const children: string[] = [];
// Description as main text content
if (item.description) {
children.push(` ${item.description}`);
}
// Associated subjects (actors)
if (item.associatedSubjects && item.associatedSubjects.length > 0) {
const subjects = item.associatedSubjects
.filter((s) => s?.name)
.map((s) => `${s.name}${s.type ? ` (${s.type})` : ''}`)
.join(', ');
if (subjects) {
children.push(` <subjects>${subjects}</subjects>`);
}
}
// Associated objects (resources)
if (item.associatedObjects && item.associatedObjects.length > 0) {
const objects = item.associatedObjects
.filter((o) => o?.name)
.map((o) => `${o.name}${o.type ? ` (${o.type})` : ''}`)
.join(', ');
if (objects) {
children.push(` <objects>${objects}</objects>`);
}
}
const content = children.length > 0 ? `\n${children.join('\n')}\n ` : '';
return ` <context ${attrs.join(' ')}>${content}</context>`;
};
/**
* Format a single experience memory item for search results
* Format: attributes for metadata, situation and keyLearning as child elements
*/
const formatExperienceResult = (item: ExperienceResult): string => {
const attrs: string[] = [`id="${item.id}"`];
if (item.type) {
attrs.push(`type="${item.type}"`);
}
if (item.scoreConfidence !== null && item.scoreConfidence !== undefined) {
attrs.push(`confidence=${item.scoreConfidence}`);
}
const children: string[] = [];
if (item.situation) {
children.push(` <situation>${item.situation}</situation>`);
}
if (item.keyLearning) {
children.push(` <keyLearning>${item.keyLearning}</keyLearning>`);
}
const content = children.length > 0 ? `\n${children.join('\n')}\n ` : '';
return ` <experience ${attrs.join(' ')}>${content}</experience>`;
};
/**
* Format a single preference memory item for search results
* Format: attributes for metadata, directives as text content
*/
const formatPreferenceResult = (item: PreferenceResult): string => {
const attrs: string[] = [`id="${item.id}"`];
if (item.type) {
attrs.push(`type="${item.type}"`);
}
if (item.scorePriority !== null && item.scorePriority !== undefined) {
attrs.push(`priority=${item.scorePriority}`);
}
const content = item.conclusionDirectives || '';
return ` <preference ${attrs.join(' ')}>${content}</preference>`;
};
export interface FormatSearchResultsOptions {
/** The search query that was used */
query: string;
/** The search results to format */
results: SearchMemoryResult;
}
/**
* Format memory search results as XML for LLM consumption.
*
* This function formats the complete search results with all content details,
* making the retrieved memories directly usable by the LLM for reasoning
* and response generation.
*/
export const formatMemorySearchResults = ({
query,
results,
}: FormatSearchResultsOptions): string => {
const { contexts, experiences, preferences } = results;
const total = contexts.length + experiences.length + preferences.length;
if (total === 0) {
return `<memories query="${query}">
<status>No memories found matching the query.</status>
</memories>`;
}
const sections: string[] = [];
// Add contexts section
if (contexts.length > 0) {
const contextsXml = contexts.map(formatContextResult).join('\n');
sections.push(`<contexts count="${contexts.length}">\n${contextsXml}\n</contexts>`);
}
// Add experiences section
if (experiences.length > 0) {
const experiencesXml = experiences.map(formatExperienceResult).join('\n');
sections.push(`<experiences count="${experiences.length}">\n${experiencesXml}\n</experiences>`);
}
// Add preferences section
if (preferences.length > 0) {
const preferencesXml = preferences.map(formatPreferenceResult).join('\n');
sections.push(`<preferences count="${preferences.length}">\n${preferencesXml}\n</preferences>`);
}
return `<memories query="${query}" total="${total}">
${sections.join('\n')}
</memories>`;
};

View File

@@ -1,3 +1,5 @@
export * from './formatSearchResults';
/**
* User memory item interfaces
*/

View File

@@ -38,15 +38,6 @@ const ToolTitle = memo<ToolTitleProps>(({ identifier, apiName, isLoading, isAbor
const isBuiltinPlugin = builtinToolIdentifiers.includes(identifier);
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
// Debug logging for LobeHub Skill title issue
console.log('[ToolTitle Debug]', {
apiName,
identifier,
isBuiltinPlugin,
pluginMeta,
pluginTitle,
});
return (
<div
className={cx(

View File

@@ -20,7 +20,6 @@ const Copilot = memo(() => {
s.updateSystemStatus,
]);
console.log('defaultWidth:', width);
return (
<RightPanel
defaultWidth={width}