diff --git a/mem0-ts/src/oss/src/memory/index.ts b/mem0-ts/src/oss/src/memory/index.ts index 38e8027d9..01828d04d 100644 --- a/mem0-ts/src/oss/src/memory/index.ts +++ b/mem0-ts/src/oss/src/memory/index.ts @@ -19,7 +19,7 @@ import { getFactRetrievalMessages, getUpdateMemoryMessages, parseMessages, - removeCodeBlocks, + extractJson, } from "../prompts"; import { DummyHistoryManager } from "../storage/DummyHistoryManager"; import { Embedder } from "../embeddings/base"; @@ -310,7 +310,7 @@ export class Memory { { type: "json_object" }, ); - const cleanResponse = removeCodeBlocks(response as string); + const cleanResponse = extractJson(response as string); let facts: string[] = []; try { const parsed = FactRetrievalSchema.parse(JSON.parse(cleanResponse)); @@ -364,7 +364,7 @@ export class Memory { { type: "json_object" }, ); - const cleanUpdateResponse = removeCodeBlocks(updateResponse as string); + const cleanUpdateResponse = extractJson(updateResponse as string); let memoryActions: any[] = []; try { memoryActions = JSON.parse(cleanUpdateResponse).memory || []; diff --git a/mem0-ts/src/oss/src/prompts/index.ts b/mem0-ts/src/oss/src/prompts/index.ts index 96cd762dd..bf861b325 100644 --- a/mem0-ts/src/oss/src/prompts/index.ts +++ b/mem0-ts/src/oss/src/prompts/index.ts @@ -281,5 +281,48 @@ export function parseMessages(messages: string[]): string { export function removeCodeBlocks(text: string): string { // Extract content inside code fences, handling both complete and // truncated blocks (where the closing ``` never arrives). - return text.replace(/```(?:\w+)?\n?([\s\S]*?)(?:```|$)/g, "$1").trim(); + const stripped = text + .replace(/```(?:\w+)?\n?([\s\S]*?)(?:```|$)/g, "$1") + .trim(); + // Strip ... blocks emitted by reasoning models (e.g. DeepSeek) + return stripped.replace(/[\s\S]*?<\/think>/g, "").trim(); +} + +/** + * Extracts a JSON object from text that may be wrapped in explanation text. + * + * Some LLMs (especially local models like Ollama/LM Studio) return JSON + * wrapped in conversational text without code fences, e.g.: + * + * "Here are the facts I extracted:\n{\"facts\": [\"fact1\"]}\nI hope this helps!" + * + * This function first tries `removeCodeBlocks` for code-fence-wrapped JSON, + * then falls back to locating the first `{` and last `}` to extract the + * outermost JSON object. + * + * @param text - The raw LLM response text + * @returns The extracted JSON string, or the original text if no JSON object + * boundaries are found + */ +export function extractJson(text: string): string { + // Step 1: Strip code fences if present + const cleaned = removeCodeBlocks(text); + const trimmed = cleaned.trim(); + + // Step 2: Try to locate a JSON object by first `{` and last `}` boundaries + const firstBrace = trimmed.indexOf("{"); + const lastBrace = trimmed.lastIndexOf("}"); + if (firstBrace !== -1 && lastBrace > firstBrace) { + return trimmed.substring(firstBrace, lastBrace + 1); + } + + // Step 3: Try to locate a JSON array by first `[` and last `]` boundaries + const firstBracket = trimmed.indexOf("["); + const lastBracket = trimmed.lastIndexOf("]"); + if (firstBracket !== -1 && lastBracket > firstBracket) { + return trimmed.substring(firstBracket, lastBracket + 1); + } + + // No JSON boundaries found — return as-is and let the caller handle the error + return trimmed; } diff --git a/mem0-ts/src/oss/tests/extract-json.test.ts b/mem0-ts/src/oss/tests/extract-json.test.ts new file mode 100644 index 000000000..c1132c429 --- /dev/null +++ b/mem0-ts/src/oss/tests/extract-json.test.ts @@ -0,0 +1,151 @@ +import { extractJson } from "../src/prompts"; + +describe("extractJson", () => { + it("returns clean JSON unchanged", () => { + const input = '{"facts": ["hello", "world"]}'; + expect(extractJson(input)).toBe('{"facts": ["hello", "world"]}'); + }); + + it("extracts JSON from ```json code fence", () => { + const input = '```json\n{"facts": ["hello"]}\n```'; + expect(extractJson(input)).toBe('{"facts": ["hello"]}'); + }); + + it("extracts JSON from bare ``` code fence", () => { + const input = '```\n{"facts": ["test"]}\n```'; + expect(extractJson(input)).toBe('{"facts": ["test"]}'); + }); + + it("extracts JSON wrapped in explanation text without code fences", () => { + const input = + 'Here are the facts I extracted:\n{"facts": ["fact1", "fact2"]}\nI hope this helps!'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ facts: ["fact1", "fact2"] }); + }); + + it("extracts JSON from chatty LLM response with leading text", () => { + const input = + 'Based on the conversation, here is the JSON output:\n{"facts": ["Name is John", "Is a software engineer"]}'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + facts: ["Name is John", "Is a software engineer"], + }); + }); + + it("extracts JSON from chatty LLM response with trailing text", () => { + const input = + '{"facts": ["Loves pizza"]}\nLet me know if you need anything else!'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ facts: ["Loves pizza"] }); + }); + + it("extracts JSON from text with both leading and trailing explanation", () => { + const input = + "Sure! Here's the extracted information:\n" + + '{"memory": [{"id": "0", "text": "Name is John", "event": "NONE"}]}\n' + + "I've analyzed the conversation above."; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + memory: [{ id: "0", text: "Name is John", event: "NONE" }], + }); + }); + + it("extracts JSON from code-fenced response with surrounding text", () => { + const input = + 'Here is the JSON:\n```json\n{"facts": ["user likes TypeScript"]}\n```\nHope this helps!'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + facts: ["user likes TypeScript"], + }); + }); + + it("handles nested JSON objects", () => { + const input = + 'The output is: {"memory": [{"id": "0", "text": "test", "event": "ADD"}]}'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + memory: [{ id: "0", text: "test", event: "ADD" }], + }); + }); + + it("handles multi-line JSON in chatty text", () => { + const input = `Here are the facts: +{ + "facts": [ + "Sky is blue", + "Grass is green" + ] +} +That's all I found.`; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + facts: ["Sky is blue", "Grass is green"], + }); + }); + + it("returns original text when no JSON boundaries found", () => { + const input = "No JSON here, just some plain text response."; + expect(extractJson(input)).toBe( + "No JSON here, just some plain text response.", + ); + }); + + it("handles JSON array responses", () => { + const input = 'The results are: ["fact1", "fact2", "fact3"]'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual(["fact1", "fact2", "fact3"]); + }); + + it("returns empty string for empty input", () => { + expect(extractJson("")).toBe(""); + }); + + it("handles truncated code block missing closing fence", () => { + const input = '```json\n{"facts": ["hello"]}'; + expect(extractJson(input)).toBe('{"facts": ["hello"]}'); + }); + + it("handles whitespace-padded JSON", () => { + const input = ' {"facts": ["test"]} '; + expect(extractJson(input)).toBe('{"facts": ["test"]}'); + }); + + it("handles LM Studio-style verbose response", () => { + const input = + "I'll analyze the conversation and extract the relevant facts.\n\n" + + '{"facts": ["User prefers dark mode", "User uses VS Code"]}\n\n' + + "These are the key preferences I identified from the conversation."; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + facts: ["User prefers dark mode", "User uses VS Code"], + }); + }); + + it("handles Ollama-style response with thinking prefix", () => { + const input = + "Let me think about this...\n\n" + + "After analyzing the input, here is my response:\n" + + '{"facts": ["Has a dog named Max"]}'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + facts: ["Has a dog named Max"], + }); + }); + + it("strips blocks from reasoning models before extracting JSON", () => { + const input = + "\nLet me analyze the conversation carefully.\n\n" + + '{"facts": ["User lives in Tokyo"]}'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ + facts: ["User lives in Tokyo"], + }); + }); + + it("handles blocks inside code fences", () => { + const input = + '```json\nreasoning here\n{"facts": ["test"]}\n```'; + const result = extractJson(input); + expect(JSON.parse(result)).toEqual({ facts: ["test"] }); + }); +}); diff --git a/mem0-ts/src/oss/tests/remove-code-blocks.test.ts b/mem0-ts/src/oss/tests/remove-code-blocks.test.ts index 4453bfa14..e6b24b19b 100644 --- a/mem0-ts/src/oss/tests/remove-code-blocks.test.ts +++ b/mem0-ts/src/oss/tests/remove-code-blocks.test.ts @@ -65,4 +65,23 @@ describe("removeCodeBlocks", () => { const input = '```json\r\n{"facts": ["hello"]}\r\n```'; expect(removeCodeBlocks(input)).toBe('{"facts": ["hello"]}'); }); + + it("strips blocks from reasoning models", () => { + const input = + 'Let me analyze this conversation...\n{"facts": ["Name is John"]}'; + expect(removeCodeBlocks(input)).toBe('{"facts": ["Name is John"]}'); + }); + + it("strips blocks inside code fences", () => { + const input = + '```json\nthinking about it\n{"facts": ["test"]}\n```'; + expect(removeCodeBlocks(input)).toBe('{"facts": ["test"]}'); + }); + + it("strips multi-line blocks", () => { + const input = + "\nStep 1: Read the conversation\nStep 2: Extract facts\n\n" + + '{"facts": ["Likes pizza"]}'; + expect(removeCodeBlocks(input)).toBe('{"facts": ["Likes pizza"]}'); + }); });