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
1 change: 1 addition & 0 deletions docs/cli/me-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ me memory search [query] [options]
| `--meta <json>` | Metadata filter as JSON. |
| `--limit <n>` | Max results (default: 10). |
| `--candidate-limit <n>` | Pre-RRF candidate pool size. |
| `--semantic-threshold <n>` | Minimum semantic similarity score, 0-1. |
| `--temporal-contains <ts>` | Memory must contain this point in time. |
| `--temporal-overlaps <range>` | Memory must overlap this range (`start,end`). |
| `--temporal-within <range>` | Memory must be within this range (`start,end`). |
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/me-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ The UI talks to whichever engine is active for the current server — same resol
```

- **Tree** (left): ltree paths as collapsible nodes, memories as leaves. Right-click a node for a context menu (delete memory / delete subtree).
- **Search** (top): simple hybrid search by default; flip to Advanced for every field accepted by `memory.search` (semantic, fulltext, grep, tree, meta, temporal, limit, candidateLimit, weights, orderBy).
- **Search** (top): simple hybrid search by default; flip to Advanced for every field accepted by `memory.search` (semantic, fulltext, grep, tree, meta, temporal, limit, candidateLimit, semanticThreshold, weights, orderBy).
- **Viewer / Editor** (right): rendered Markdown with syntax highlighting, or the Monaco editor with YAML frontmatter + body. The copy button copies the Markdown source with frontmatter. Save is disabled until you make a valid change. The read-only metadata panel sits below.
- **URL state**: filter fields and the selected memory id are reflected in the URL, so any view can be shared or bookmarked.

Expand Down
4 changes: 3 additions & 1 deletion docs/mcp/me_memory_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Supports three search modes: **semantic** (meaning-based), **fulltext** (keyword
| `temporal` | `object \| null` | no | Temporal filter. Omit or pass `null` to skip. |
| `weights` | `object \| null` | no | Weights for hybrid search ranking. Omit or pass `null` for defaults. |
| `candidateLimit` | `integer \| null` | no | Candidates per search mode before RRF fusion. Omit or pass `null` for default (30). |
| `semanticThreshold` | `number \| null` | no | Minimum semantic similarity score (0-1) for vector candidates. Omit or pass `null` to skip. |
| `limit` | `integer \| null` | no | Maximum number of results. Omit or pass `null` for default (10). Max: 1000. |
| `order_by` | `string \| null` | no | Sort direction for filter-only searches: `"asc"` or `"desc"`. Default: `"desc"`. Omit or pass `null` for default. |

Expand Down Expand Up @@ -83,7 +84,8 @@ See [Tree filter syntax](../concepts.md#tree-filter-syntax) for the full referen
```json
{
"semantic": "how does authentication work",
"limit": 10
"limit": 10,
"semanticThreshold": 0.7
}
```

Expand Down
1 change: 1 addition & 0 deletions docs/typescript-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ const { results, total } = await me.memory.search({
// Tuning
limit: 10, // max results (1-1000)
candidateLimit: 30, // candidates per mode before RRF fusion
semanticThreshold: 0.7, // optional min semantic score (0-1)
weights: { semantic: 0.7, fulltext: 0.3 },
orderBy: "desc", // for filter-only queries (no search)
});
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ function createMemorySearchCommand(): Command {
.option("--meta <json>", "metadata filter (JSON)")
.option("--limit <n>", "max results", "10")
.option("--candidate-limit <n>", "pre-RRF candidate pool size")
.option("--semantic-threshold <n>", "minimum semantic score (0-1)")
.option("--temporal-contains <ts>", "memory must contain this point")
.option("--temporal-overlaps <range>", "memory must overlap (start,end)")
.option("--temporal-within <range>", "memory must be within (start,end)")
Expand Down Expand Up @@ -322,6 +323,8 @@ function createMemorySearchCommand(): Command {
if (weights) params.weights = weights;
if (opts.candidateLimit)
params.candidateLimit = Number.parseInt(opts.candidateLimit, 10);
if (opts.semanticThreshold)
params.semanticThreshold = Number.parseFloat(opts.semanticThreshold);
if (opts.orderBy) params.orderBy = opts.orderBy;

const result = await engine.memory.search(
Expand Down
10 changes: 10 additions & 0 deletions packages/cli/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,15 @@ Docs: ${docUrl("me_memory_search")}`,
.describe(
"Candidates per search mode before RRF fusion (0 = default 30)",
),
semanticThreshold: z
.number()
.min(0)
.max(1)
.optional()
.nullable()
.describe(
"Minimum semantic similarity score (0-1) for vector candidates",
),
limit: z
.number()
.int()
Expand Down Expand Up @@ -250,6 +259,7 @@ Docs: ${docUrl("me_memory_search")}`,
? args.candidateLimit
: undefined,
limit: args.limit && args.limit > 0 ? args.limit : undefined,
semanticThreshold: args.semanticThreshold ?? undefined,
orderBy:
(args.order_by as "asc" | "desc" | null | undefined) ?? undefined,
});
Expand Down
18 changes: 15 additions & 3 deletions packages/engine/ops/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,18 @@ async function buildSemanticQuery(
params: FilterParams & {
embedding: number[];
limit: number;
semanticThreshold?: number;
},
): Promise<SearchRow[]> {
const { clauses, values } = buildCommonFilters(params, 2); // $1=embedding, $2=limit

const hasSemanticThreshold = typeof params.semanticThreshold === "number";
const { clauses, values } = buildCommonFilters(
params,
hasSemanticThreshold ? 3 : 2,
); // $1=embedding, $2=limit, optional $3=semanticThreshold

const semanticThresholdClause = hasSemanticThreshold
? "AND (1 - (embedding <=> $1::halfvec)) >= $3"
: "AND (embedding <=> $1::halfvec) < 1.0";
const whereClause = clauses.length > 0 ? `AND ${clauses.join(" AND ")}` : "";

// Format embedding as PostgreSQL array literal
Expand All @@ -333,7 +341,7 @@ async function buildSemanticQuery(
(1 - (embedding <=> $1::halfvec)) as score
FROM ${schema}.memory
WHERE embedding IS NOT NULL
AND (embedding <=> $1::halfvec) < 1.0
${semanticThresholdClause}
${whereClause}
ORDER BY score DESC, created_at DESC
LIMIT $2
Expand All @@ -342,6 +350,7 @@ async function buildSemanticQuery(
return sql.unsafe<SearchRow[]>(query, [
embeddingLiteral,
params.limit,
...(hasSemanticThreshold ? [params.semanticThreshold] : []),
...values,
]);
}
Expand Down Expand Up @@ -626,6 +635,7 @@ export function memoryOps(ctx: OpsContext) {
temporal,
limit = 10,
candidateLimit = 30,
semanticThreshold,
weights = { fulltext: 1.0, semantic: 1.0 },
orderBy = "desc",
} = params;
Expand All @@ -651,6 +661,7 @@ export function memoryOps(ctx: OpsContext) {
tree,
temporal,
limit: candidateLimit,
semanticThreshold,
}),
]);

Expand Down Expand Up @@ -689,6 +700,7 @@ export function memoryOps(ctx: OpsContext) {
tree,
temporal,
limit,
semanticThreshold,
});
results = rows.map(rowToSearchResult);
} else {
Expand Down
2 changes: 2 additions & 0 deletions packages/engine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ export interface SearchParams {
limit?: number;
/** Candidates per search mode before RRF fusion */
candidateLimit?: number;
/** Minimum semantic similarity score (0-1) for vector candidates */
semanticThreshold?: number;
/** Weights for hybrid search */
weights?: SearchWeights;
/** Sort direction for filter-only searches */
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/engine/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const memorySearchParams = z.object({
temporal: temporalFilterSchema.optional().nullable(),
limit: z.number().int().min(1).max(1000).optional(),
candidateLimit: z.number().int().min(1).max(1000).optional(),
semanticThreshold: z.number().min(0).max(1).optional().nullable(),
weights: searchWeightsSchema.optional().nullable(),
orderBy: z.enum(["asc", "desc"]).optional(),
});
Expand Down
1 change: 1 addition & 0 deletions packages/server/rpc/engine/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ async function memorySearch(
temporal: parseTemporalFilter(params.temporal),
limit: params.limit,
candidateLimit: params.candidateLimit,
semanticThreshold: params.semanticThreshold ?? undefined,
weights: params.weights ?? undefined,
orderBy: params.orderBy,
});
Expand Down
3 changes: 3 additions & 0 deletions packages/web/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,9 @@ export function normalizeSearchParams(
if (typeof params.candidateLimit === "number") {
out.candidateLimit = params.candidateLimit;
}
if (typeof params.semanticThreshold === "number") {
out.semanticThreshold = params.semanticThreshold;
}

const hasAnyFilter =
out.semantic !== undefined ||
Expand Down
16 changes: 15 additions & 1 deletion packages/web/src/components/search/AdvancedSearchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export function AdvancedSearchPanel() {
<NumberInput
value={advanced.limit}
onChange={(v) => setAdvanced({ limit: v })}
placeholder="1000 (default)"
placeholder="50 semantic-only; otherwise 1000"
min={1}
max={1000}
/>
Expand All @@ -137,6 +137,20 @@ export function AdvancedSearchPanel() {
/>
</Field>

<Field label="semantic threshold (0–1)">
<NumberInput
value={advanced.semanticThreshold}
onChange={(v) => setAdvanced({ semanticThreshold: v })}
placeholder="optional min score"
step="0.01"
min={0}
max={1}
/>
<p className="mt-1 text-xs text-slate-500">
Filters semantic candidates before ranking. Higher is stricter.
</p>
</Field>

<Field label="weights.semantic (0–1)">
<NumberInput
value={advanced.weightsSemantic}
Expand Down
1 change: 1 addition & 0 deletions packages/web/src/lib/url-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ describe("encode/decode round-trip", () => {
},
limit: "500",
candidateLimit: "200",
semanticThreshold: "0.72",
weightsSemantic: "0.7",
weightsFulltext: "0.3",
orderBy: "desc",
Expand Down
2 changes: 2 additions & 0 deletions packages/web/src/lib/url-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export function encodeUrlState(
if (a.temporal.end) p.set("temporal_end", a.temporal.end);
if (a.limit) p.set("limit", a.limit);
if (a.candidateLimit) p.set("candidate_limit", a.candidateLimit);
if (a.semanticThreshold) p.set("semantic_threshold", a.semanticThreshold);
if (a.weightsSemantic) p.set("weights_semantic", a.weightsSemantic);
if (a.weightsFulltext) p.set("weights_fulltext", a.weightsFulltext);
if (a.orderBy) p.set("order_by", a.orderBy);
Expand Down Expand Up @@ -80,6 +81,7 @@ export function decodeUrlState(search: string): {
},
limit: p.get("limit") ?? "",
candidateLimit: p.get("candidate_limit") ?? "",
semanticThreshold: p.get("semantic_threshold") ?? "",
weightsSemantic: p.get("weights_semantic") ?? "",
weightsFulltext: p.get("weights_fulltext") ?? "",
orderBy: coerceOrderBy(p.get("order_by")),
Expand Down
27 changes: 27 additions & 0 deletions packages/web/src/store/filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,31 @@ describe("mergeMetaJsonFilter", () => {
});

describe("selectSearchParams", () => {
test("uses a smaller default limit for advanced semantic-only search", () => {
expect(selectSearchParams(withAdvanced({ semantic: "hello" })).limit).toBe(
50,
);
});

test("preserves explicit limits for advanced semantic-only search", () => {
expect(
selectSearchParams(withAdvanced({ semantic: "hello", limit: "10" }))
.limit,
).toBe(10);
});

test("passes semantic threshold through only when semantic search is set", () => {
expect(
selectSearchParams(
withAdvanced({ semantic: "hello", semanticThreshold: "0.72" }),
).semanticThreshold,
).toBe(0.72);
expect(
selectSearchParams(withAdvanced({ semanticThreshold: "0.72" }))
.semanticThreshold,
).toBeUndefined();
});

test("converts datetime-local temporal contains filters to offset ISO datetimes", () => {
const start = "2026-01-01T12:34:56";

Expand Down Expand Up @@ -159,6 +184,7 @@ describe("summarizeFilter (advanced mode)", () => {
tree: "work.*",
limit: "50",
candidateLimit: "25",
semanticThreshold: "0.72",
orderBy: "asc",
}),
);
Expand All @@ -169,6 +195,7 @@ describe("summarizeFilter (advanced mode)", () => {
`tree: work.*`,
`limit: 50`,
`candidateLimit: 25`,
`semanticThreshold: 0.72`,
`order: asc`,
]);
});
Expand Down
29 changes: 27 additions & 2 deletions packages/web/src/store/filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,11 @@ export interface AdvancedFilter {
start: string;
end: string;
};
/** Empty string means "use default (1000)". */
/** Empty string means "use default (1000, or 50 for semantic-only)". */
limit: string;
candidateLimit: string;
/** Minimum vector similarity score (0-1) for semantic candidates. */
semanticThreshold: string;
weightsSemantic: string;
weightsFulltext: string;
orderBy: "" | "asc" | "desc";
Expand Down Expand Up @@ -67,6 +69,7 @@ export const EMPTY_ADVANCED: AdvancedFilter = {
temporal: { mode: "overlaps", start: "", end: "" },
limit: "",
candidateLimit: "",
semanticThreshold: "",
weightsSemantic: "",
weightsFulltext: "",
orderBy: "",
Expand Down Expand Up @@ -147,11 +150,20 @@ export function selectSearchParams(state: FilterState): MemorySearchParams {
if (temporal) params.temporal = temporal;

const limit = parseIntOrUndef(a.limit);
if (limit !== undefined) params.limit = limit;
if (limit !== undefined) {
params.limit = limit;
} else if (params.semantic && !params.fulltext) {
params.limit = 50;
}

const candidateLimit = parseIntOrUndef(a.candidateLimit);
if (candidateLimit !== undefined) params.candidateLimit = candidateLimit;

const semanticThreshold = parseFloatInRangeOrUndef(a.semanticThreshold, 0, 1);
if (semanticThreshold !== undefined && params.semantic) {
params.semanticThreshold = semanticThreshold;
}

const ws = parseFloatOrUndef(a.weightsSemantic);
const wf = parseFloatOrUndef(a.weightsFulltext);
if (ws !== undefined || wf !== undefined) {
Expand Down Expand Up @@ -197,6 +209,16 @@ function parseFloatOrUndef(s: string): number | undefined {
return Number.isFinite(n) ? n : undefined;
}

function parseFloatInRangeOrUndef(
s: string,
min: number,
max: number,
): number | undefined {
const n = parseFloatOrUndef(s);
if (n === undefined) return undefined;
return n >= min && n <= max ? n : undefined;
}

/**
* Human-readable summary of the active filter, used by the collapsed
* search bar.
Expand Down Expand Up @@ -238,6 +260,9 @@ export function summarizeFilter(state: FilterState): {
if (a.candidateLimit.trim()) {
chips.push(`candidateLimit: ${a.candidateLimit.trim()}`);
}
if (a.semantic.trim() && a.semanticThreshold.trim()) {
chips.push(`semanticThreshold: ${a.semanticThreshold.trim()}`);
}

const ws = a.weightsSemantic.trim();
const wf = a.weightsFulltext.trim();
Expand Down