Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
173 changes: 138 additions & 35 deletions packages/openmemory-js/src/ai/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,55 @@ const sec_enum = z.enum([
const trunc = (val: string, max = 200) =>
val.length <= max ? val : `${val.slice(0, max).trimEnd()}...`;

/** Cap HSG / unbounded graph work so a hung embed or huge scan cannot drop Streamable HTTP. */
const query_timeout_ms = () => {
const n = Number(process.env.OM_MCP_QUERY_TIMEOUT_MS);
return Number.isFinite(n) && n > 0 ? n : 12_000;
};

const with_timeout = async <T>(
p: Promise<T>,
ms: number,
label: string,
): Promise<T> => {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
p,
new Promise<T>((_, reject) => {
timer = setTimeout(
() =>
reject(
new Error(
`${label} timed out after ${ms}ms; try a tighter query or type=factual with fact_pattern`,
),
),
ms,
);
if (typeof (timer as NodeJS.Timeout).unref === "function") {
(timer as NodeJS.Timeout).unref();
}
}),
]);
} finally {
if (timer) clearTimeout(timer);
// Timed-out work may still reject later; swallow so the HTTP
// session is not taken down by an unhandled rejection.
p.catch(() => {});
}
};

const tool_error = (msg: string) => ({
content: [{ type: "text" as const, text: msg }],
isError: true as const,
});

const has_fact_pattern = (fp?: {
subject?: string;
predicate?: string;
object?: string;
}) => Boolean(fp?.subject || fp?.predicate || fp?.object);

const build_mem_snap = (row: mem_row) => ({
id: row.id,
primary_sector: row.primary_sector,
Expand Down Expand Up @@ -140,7 +189,7 @@ export const create_mcp_srv = (tenant?: string) => {
.optional()
.default("contextual")
.describe(
"Query type: 'contextual' for HSG semantic search (default), 'factual' for temporal fact queries, 'unified' for both",
"Query type: 'contextual' for HSG semantic search (default; fails soft on embed/search errors), 'factual' for temporal fact queries (pass fact_pattern or results are capped at k), 'unified' for both",
),
fact_pattern: z
.object({
Expand Down Expand Up @@ -179,7 +228,9 @@ export const create_mcp_srv = (tenant?: string) => {
.min(1)
.max(32)
.default(8)
.describe("Maximum results to return (for HSG queries)"),
.describe(
"Maximum results to return. Applies to HSG queries and to factual queries that omit fact_pattern",
),
sector: sec_enum
.optional()
.describe(
Expand Down Expand Up @@ -221,6 +272,8 @@ export const create_mcp_srv = (tenant?: string) => {
const proj = uid(project_id);
const results: any = { type, query };
const at_date = at ? new Date(at) : new Date();
const top_k = k ?? 8;
const warnings: string[] = [];

if (type === "contextual" || type === "unified") {
const flt =
Expand All @@ -237,44 +290,87 @@ export const create_mcp_srv = (tenant?: string) => {
}
: undefined;

const matches = await hsg_query(query, k ?? 8, flt);
results.contextual = matches.map((m: any) => ({
source: "hsg",
id: m.id,
score: Number(m.score.toFixed(4)),
primary_sector: m.primary_sector,
sectors: m.sectors,
salience: Number(m.salience.toFixed(4)),
last_seen_at: m.last_seen_at,
path: m.path,
content: m.content,
}));
try {
const matches = await with_timeout(
hsg_query(query, top_k, flt),
query_timeout_ms(),
"openmemory_query HSG search",
);
results.contextual = matches.map((m: any) => ({
source: "hsg",
id: m.id,
score: Number(m.score.toFixed(4)),
primary_sector: m.primary_sector,
sectors: m.sectors,
salience: Number(m.salience.toFixed(4)),
last_seen_at: m.last_seen_at,
path: m.path,
content: m.content,
}));
} catch (err: any) {
const msg = err?.message || "HSG contextual search failed";
// Default type is contextual: never let a hung/failed
// embed drop the Streamable HTTP POST. Unified can still
// return facts; pure contextual surfaces a tool error.
if (type === "contextual") {
return tool_error(`openmemory_query failed: ${msg}`);
}
warnings.push(`contextual search failed: ${msg}`);
results.contextual = [];
results.contextual_error = msg;
}
}

if (type === "factual" || type === "unified") {
const facts = await query_facts_at_time({
user_id: u ?? "anonymous",
project_id: proj,
subject: fact_pattern?.subject,
predicate: fact_pattern?.predicate,
object: fact_pattern?.object,
at: at_date,
min_confidence: 0.0,
});

results.factual = facts.map((f: any) => ({
source: "temporal",
id: f.id,
subject: f.subject,
predicate: f.predicate,
object: f.object,
valid_from: f.valid_from,
valid_to: f.valid_to,
confidence: Number(f.confidence.toFixed(4)),
content: `${f.subject} ${f.predicate} ${f.object}`,
}));
const patterned = has_fact_pattern(fact_pattern);
try {
const facts = await with_timeout(
query_facts_at_time({
user_id: u ?? "anonymous",
project_id: proj,
subject: fact_pattern?.subject,
predicate: fact_pattern?.predicate,
object: fact_pattern?.object,
at: at_date,
min_confidence: 0.0,
// Unpatterned dumps can exceed 1MB and stall the
// MCP session; always cap, tighter without a pattern.
limit: patterned ? Math.max(top_k, 32) : top_k,
}),
query_timeout_ms(),
"openmemory_query factual search",
);

results.factual = facts.map((f: any) => ({
source: "temporal",
id: f.id,
subject: f.subject,
predicate: f.predicate,
object: f.object,
valid_from: f.valid_from,
valid_to: f.valid_to,
confidence: Number(f.confidence.toFixed(4)),
content: `${f.subject} ${f.predicate} ${f.object}`,
}));
if (!patterned) {
results.factual_capped = top_k;
warnings.push(
`factual results capped at k=${top_k}; pass fact_pattern to narrow the graph scan`,
);
}
} catch (err: any) {
const msg = err?.message || "temporal fact query failed";
if (type === "factual") {
return tool_error(`openmemory_query failed: ${msg}`);
}
warnings.push(`factual search failed: ${msg}`);
results.factual = [];
results.factual_error = msg;
}
}

if (warnings.length) results.warnings = warnings;

let summ = "";
if (type === "contextual") {
summ = results.contextual.length
Expand Down Expand Up @@ -316,6 +412,13 @@ export const create_mcp_srv = (tenant?: string) => {
}
}

if (warnings.length) {
summ =
warnings.map((w) => `Warning: ${w}`).join("\n") +
"\n\n" +
summ;
}

return {
content: [
{ type: "text", text: summ },
Expand Down
9 changes: 8 additions & 1 deletion packages/openmemory-js/src/temporal_graph/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export const query_facts_at_time = async (opts: {
object?: string;
at?: Date;
min_confidence?: number;
limit?: number;
}): Promise<TemporalFact[]> => {
const {
user_id,
Expand All @@ -18,6 +19,7 @@ export const query_facts_at_time = async (opts: {
object,
at = new Date(),
min_confidence = 0.1,
limit,
} = opts;
const timestamp = at.getTime();
const conditions: string[] = [];
Expand Down Expand Up @@ -58,12 +60,17 @@ export const query_facts_at_time = async (opts: {
params.push(min_confidence);
}

const sql = `
let sql = `
SELECT id, user_id, project_id, subject, predicate, object, valid_from, valid_to, confidence, last_updated, metadata
FROM temporal_facts
WHERE ${conditions.join(" AND ")}
ORDER BY confidence DESC, valid_from DESC
`;
if (typeof limit === "number" && Number.isFinite(limit)) {
const n = Math.max(1, Math.min(Math.floor(limit), 32));
sql += " LIMIT ?";
params.push(n);
}

const rows = await all_async(sql, params);
return rows.map((row) => ({
Expand Down
Loading
Loading