diff --git a/plugins/memory/index.js b/plugins/memory/index.js index 17d54f6..8dc17a7 100644 --- a/plugins/memory/index.js +++ b/plugins/memory/index.js @@ -138,6 +138,33 @@ function extractEntities(text) { return entities; } +// ─── Helper: coerce a tags value to an array ───────────────────────────────── + +/** + * LLMs occasionally serialize array arguments as a JSON string + * (e.g. '["work","urgent"]' instead of ["work","urgent"]). + * This helper normalises both forms so downstream code always receives + * a plain JS array (or undefined/null for missing values). + */ +function coerceToArray(value) { + if (value == null) return value; + if (Array.isArray(value)) return value; + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.startsWith("[")) { + try { + const parsed = JSON.parse(trimmed); + if (Array.isArray(parsed)) return parsed; + } catch { + // Not valid JSON — fall through and treat as a single tag + } + } + // Single tag provided as a plain string (e.g. "work") + return trimmed ? [trimmed] : []; + } + return value; +} + // ─── Helper: parse tags from content and explicit list ─────────────────────── function parseTags(content, extraTags) { @@ -148,9 +175,10 @@ function parseTags(content, extraTags) { tags.add(m[1].toLowerCase()); } - // Explicitly provided tags - if (Array.isArray(extraTags)) { - for (const t of extraTags) { + // Explicitly provided tags — coerce to array first to handle JSON strings + const normalised = coerceToArray(extraTags); + if (Array.isArray(normalised)) { + for (const t of normalised) { if (typeof t === "string" && t.trim()) { tags.add(t.replace(/^#/, "").toLowerCase().trim()); } @@ -205,9 +233,11 @@ export const tools = (sdk) => [ description: "The text to remember. May include inline #tags and @mentions.", }, tags: { - type: "array", - items: { type: "string" }, - description: "Optional list of tags to attach (e.g. [\"work\", \"urgent\"]). #prefix is optional.", + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "string" }, + ], + description: "Optional list of tags to attach (e.g. [\"work\", \"urgent\"]). #prefix is optional. May also be provided as a JSON-encoded string.", }, }, required: ["content"], @@ -358,9 +388,11 @@ export const tools = (sdk) => [ description: "Free-text search within memory content (case-insensitive substring match).", }, tags: { - type: "array", - items: { type: "string" }, - description: "Filter entries that have ALL of the specified tags.", + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "string" }, + ], + description: "Filter entries that have ALL of the specified tags. May also be provided as a JSON-encoded string.", }, entity: { type: "string", @@ -438,8 +470,10 @@ export const tools = (sdk) => [ } // Tag filtering: entry must have ALL requested tags - const normalizedTags = Array.isArray(filterTags) - ? filterTags.map((t) => t.replace(/^#/, "").toLowerCase().trim()).filter(Boolean) + // coerceToArray handles JSON-string inputs from LLMs + const coercedTags = coerceToArray(filterTags); + const normalizedTags = Array.isArray(coercedTags) + ? coercedTags.map((t) => t.replace(/^#/, "").toLowerCase().trim()).filter(Boolean) : []; for (const tag of normalizedTags) { @@ -515,9 +549,11 @@ export const tools = (sdk) => [ description: "New content to replace the existing entry. May include inline #tags and @mentions.", }, tags: { - type: "array", - items: { type: "string" }, - description: "Optional list of tags to attach (replaces existing tags). #prefix is optional.", + oneOf: [ + { type: "array", items: { type: "string" } }, + { type: "string" }, + ], + description: "Optional list of tags to attach (replaces existing tags). #prefix is optional. May also be provided as a JSON-encoded string.", }, }, required: ["id"], diff --git a/plugins/memory/tests/index.test.js b/plugins/memory/tests/index.test.js index 99e930a..708426c 100644 --- a/plugins/memory/tests/index.test.js +++ b/plugins/memory/tests/index.test.js @@ -378,6 +378,29 @@ describe("memory plugin", () => { assert.ok(personEntity, "should extract @anton as person entity"); }); + it("accepts tags as a JSON-encoded string (LLM serialization quirk)", async () => { + const sdk = makeSdk(); + const tool = mod.tools(sdk).find((t) => t.name === "memory_store"); + const result = await tool.execute( + { content: "Important rule", tags: '["rules", "github"]' }, + makeContext() + ); + assert.equal(result.success, true); + assert.ok(result.data.tags.includes("#rules"), "should parse #rules from JSON string"); + assert.ok(result.data.tags.includes("#github"), "should parse #github from JSON string"); + }); + + it("accepts tags as a single plain string", async () => { + const sdk = makeSdk(); + const tool = mod.tools(sdk).find((t) => t.name === "memory_store"); + const result = await tool.execute( + { content: "Note", tags: "work" }, + makeContext() + ); + assert.equal(result.success, true); + assert.ok(result.data.tags.includes("#work"), "should treat plain string as a single tag"); + }); + it("returns error when content is empty", async () => { const sdk = makeSdk(); const tool = mod.tools(sdk).find((t) => t.name === "memory_store"); @@ -462,6 +485,17 @@ describe("memory plugin", () => { assert.equal(result.success, false); assert.ok(result.error.includes("end_date")); }); + + it("accepts tags filter as a JSON-encoded string (LLM serialization quirk)", async () => { + const sdk = makeSdk(); + const store = mod.tools(sdk).find((t) => t.name === "memory_store"); + await store.execute({ content: "GitHub workflow note", tags: ["github"] }, makeContext()); + + const search = mod.tools(sdk).find((t) => t.name === "memory_search"); + const result = await search.execute({ tags: '["github"]' }, makeContext()); + assert.equal(result.success, true); + assert.ok(result.data.count >= 1, "should find entry via JSON-string tags filter"); + }); }); // ── memory_update ────────────────────────────────────────────────────────────