Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions docs/METADATA_BEHAVIOR.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,14 @@ on memories. It is intentionally a product/runtime spec, not an experiment note.
## Recall Response Shape

- Recall results include parsed `memory.metadata` when the graph or Qdrant
payload provides it.
- `json` and detailed recall formats expose the same memory metadata object in
result payloads; malformed graph metadata is treated as an empty or raw parsed
value depending on the caller path.
payload provides it, along with `updated_at` and `last_accessed` timestamps;
malformed graph metadata is treated as an empty or raw parsed value depending
on the caller path.
- The MCP server's `json` recall format passes the raw response through, so it
exposes the full metadata object. The MCP `detailed` format renders a
full `Metadata:` line as single-line JSON plus an `Updated:` line when
present, and omits the metadata line entirely for empty or missing metadata.
The `text` and `items` formats do not include metadata.
- Final scoring can use metadata terms as weak evidence for candidates that are
already present from another channel.

Expand Down
14 changes: 13 additions & 1 deletion mcp-sse-server/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,7 @@ export function formatRecallAsItems(results, { detailed = false } = {}) {
if (id) lines.push(`ID: ${id}`);
if (mem.type) lines.push(`Type: ${String(mem.type)}`);
if (mem.timestamp) lines.push(`Timestamp: ${String(mem.timestamp)}`);
if (mem.updated_at) lines.push(`Updated: ${String(mem.updated_at)}`);
if (mem.last_accessed) lines.push(`Last accessed: ${String(mem.last_accessed)}`);
if (mem.importance !== undefined) {
const imp = Number(mem.importance);
Expand All @@ -381,6 +382,17 @@ export function formatRecallAsItems(results, { detailed = false } = {}) {
lines.push(`Confidence: ${Number.isFinite(conf) ? conf.toFixed(3) : String(mem.confidence)}`);
}
if (tags.length) lines.push(`Tags: ${tags.join(', ')}`);
if (mem.metadata && typeof mem.metadata === 'object' && Object.keys(mem.metadata).length) {
let metaJson = '';
try {
metaJson = JSON.stringify(mem.metadata);
} catch (_) {
metaJson = '';
}
if (metaJson && metaJson !== '{}') {
lines.push(`Metadata: ${metaJson}`);
}
Comment thread
jack-arturo marked this conversation as resolved.
}
if (score !== undefined) lines.push(`Score: ${score.toFixed(3)}`);
if (it?.match_type) lines.push(`Match: ${String(it.match_type)}`);
if (it?.source) lines.push(`Source: ${String(it.source)}`);
Expand Down Expand Up @@ -484,7 +496,7 @@ export function buildMcpServer(client) {
type: 'string',
enum: ['text', 'items', 'detailed', 'json'],
default: 'text',
description: 'Output formatting: text (single block), items (one memory per content item), detailed (per-item with timestamps/relations), json (raw response JSON as text)',
description: 'Output formatting: text (single block), items (one memory per content item), detailed (per-item with timestamps/metadata/relations), json (raw response JSON as text)',
}
}
}
Expand Down
105 changes: 105 additions & 0 deletions mcp-sse-server/test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,12 @@ test("formatRecallAsItems supports detailed output including relations", () => {
content: "Hello world",
tags: ["automem", "cursor"],
timestamp: "2025-12-14T00:00:00Z",
updated_at: "2025-12-14T02:00:00Z",
last_accessed: "2025-12-14T01:00:00Z",
importance: 0.95,
confidence: 0.88,
type: "Insight",
metadata: { created_by: "test-agent", task: "synthetic-task" },
},
},
];
Expand All @@ -106,10 +108,12 @@ test("formatRecallAsItems supports detailed output including relations", () => {
assert.ok(detailed.includes("ID: mem-1"));
assert.ok(detailed.includes("Type: Insight"));
assert.ok(detailed.includes("Timestamp: 2025-12-14T00:00:00Z"));
assert.ok(detailed.includes("Updated: 2025-12-14T02:00:00Z"));
assert.ok(detailed.includes("Last accessed: 2025-12-14T01:00:00Z"));
assert.ok(detailed.includes("Importance: 0.950"));
assert.ok(detailed.includes("Confidence: 0.880"));
assert.ok(detailed.includes("Tags: automem, cursor"));
assert.ok(detailed.includes('Metadata: {"created_by":"test-agent","task":"synthetic-task"}'));
assert.ok(detailed.includes("Score: 0.123"));
assert.ok(detailed.includes("Match: relation"));
assert.ok(detailed.includes("Source: graph"));
Expand All @@ -118,6 +122,107 @@ test("formatRecallAsItems supports detailed output including relations", () => {
const compact = formatRecallAsItems(results, { detailed: false })[0].text;
assert.ok(compact.includes("score=0.123"));
assert.ok(compact.includes("ID: mem-1"));
assert.ok(!compact.includes("Metadata:"));
});

test("formatRecallAsItems detailed output renders full metadata and omits empty metadata", () => {
const bigMetadata = { notes: "x".repeat(400) };
const results = [
{
memory: { id: "mem-big", content: "Big metadata", metadata: bigMetadata },
},
{
memory: { id: "mem-empty", content: "Empty metadata", metadata: {} },
},
{
memory: { id: "mem-none", content: "No metadata" },
},
];

const [big, empty, none] = formatRecallAsItems(results, { detailed: true }).map(x => x.text);

const metadataLine = big.split("\n").find(line => line.startsWith("Metadata: "));
assert.ok(metadataLine, "expected a Metadata line for oversized metadata");
const rendered = metadataLine.slice("Metadata: ".length);
assert.equal(rendered, JSON.stringify(bigMetadata));

assert.ok(!empty.includes("Metadata:"));
assert.ok(!none.includes("Metadata:"));
assert.ok(!big.includes("Updated:"));
});

test("recall_memory json format passes through metadata from the API response", async () => {
const prevToken = process.env.AUTOMEM_API_TOKEN;
const prevEndpoint = process.env.AUTOMEM_API_URL;
process.env.AUTOMEM_API_TOKEN = "test-token";
process.env.AUTOMEM_API_URL = "http://upstream.test";

const originalFetch = globalThis.fetch;
const upstreamResponse = {
status: "success",
results: [
{
id: "mem-json",
final_score: 0.9,
memory: {
id: "mem-json",
content: "JSON passthrough",
metadata: { created_by: "test-agent", task: "synthetic-task" },
updated_at: "2025-12-14T02:00:00Z",
last_accessed: "2025-12-14T01:00:00Z",
},
},
],
count: 1,
};

globalThis.fetch = async (url, options) => {
if (String(url).startsWith("http://upstream.test/")) {
return new Response(JSON.stringify(upstreamResponse), {
status: 200,
headers: { "content-type": "application/json" },
});
}
return originalFetch(url, options);
};

try {
const app = createApp();
await withServer(app, async (port) => {
const res = await originalFetch(`http://127.0.0.1:${port}/mcp`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json, text/event-stream",
Authorization: "Bearer test-token",
},
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: {
name: "recall_memory",
arguments: { query: "passthrough", format: "json" },
},
}),
});

assert.equal(res.status, 200);
const body = await res.json();
const text = body.result.content[0].text;
const parsed = JSON.parse(text);
assert.deepEqual(parsed.results[0].memory.metadata, {
created_by: "test-agent",
task: "synthetic-task",
});
assert.equal(parsed.results[0].memory.updated_at, "2025-12-14T02:00:00Z");
assert.equal(parsed.results[0].memory.last_accessed, "2025-12-14T01:00:00Z");
});
} finally {
globalThis.fetch = originalFetch;
process.env.AUTOMEM_API_TOKEN = prevToken;
process.env.AUTOMEM_API_URL = prevEndpoint;
}
});

test("formatRecallAsItems surfaces outside_tag_scope fills in both formats", () => {
Expand Down
49 changes: 49 additions & 0 deletions tests/test_api_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1000,6 +1000,55 @@ def test_recall_with_explicit_timestamps(client, mock_state, auth_headers):
assert "time_window" in data


def test_recall_metadata_roundtrip(client, mock_state, auth_headers):
"""Custom metadata and timestamps stored via POST /memory surface in /recall (#111)."""
with_metadata = {
"content": "Memory with provenance metadata",
"tags": ["metadata-roundtrip", "with-metadata"],
"importance": 0.8,
"metadata": {"created_by": "test-agent", "task": "synthetic-task"},
}
without_metadata = {
"content": "Memory without metadata",
"tags": ["metadata-roundtrip", "no-metadata"],
"importance": 0.7,
}

memory_ids = {}
for key, payload in (("with", with_metadata), ("without", without_metadata)):
store_response = client.post("/memory", json=payload, headers=auth_headers)
assert store_response.status_code == 201
store_data = store_response.get_json()
assert store_data["status"] == "success"
memory_ids[key] = store_data["memory_id"]

response = client.get("/recall?tags=metadata-roundtrip&limit=10", headers=auth_headers)
assert response.status_code == 200
data = response.get_json()
assert data["status"] == "success"

results = {result["id"]: result["memory"] for result in data.get("results", [])}
assert set(results) == set(memory_ids.values())

enriched = results[memory_ids["with"]]
assert isinstance(enriched["metadata"], dict)
assert enriched["metadata"]["created_by"] == "test-agent"
assert enriched["metadata"]["task"] == "synthetic-task"
# POST /memory defaults updated_at to created_at and last_accessed to updated_at
assert enriched["updated_at"]
assert enriched["last_accessed"]

# Backward compat: memories stored without metadata round-trip without user
# metadata. JIT enrichment may add server-side bookkeeping keys only
# (written by jit_enrich_lightweight in automem/enrichment/runtime_orchestration.py).
plain = results[memory_ids["without"]]
plain_metadata = plain.get("metadata") or {}
assert isinstance(plain_metadata, dict)
assert set(plain_metadata) <= {"enrichment", "entities"}
assert plain["updated_at"]
assert plain["last_accessed"]


def test_recall_with_high_limit(client, mock_state, auth_headers):
"""Test recall with limit exceeding max - should clamp to 50."""
response = client.get("/recall?limit=100", headers=auth_headers)
Expand Down
Loading