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
4 changes: 4 additions & 0 deletions docs/cli/me-memory.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ search or `--tree`, `--meta`, or a `--temporal-*` filter. A
`--meta-predicate` companion alone does not qualify because both filters can
require broad scans.

Temporal flags can be combined; every supplied predicate must match. For
example, pair `--temporal-after <start>` with `--temporal-before <end>` to find
memories wholly between two cutoffs.

`--meta-predicate` uses PostgreSQL's JSONPath predicate syntax. Quote the
expression so the shell does not interpret `$`, `*`, or parentheses:

Expand Down
4 changes: 4 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,10 @@ Temporal ranges use PostgreSQL's `tstzrange` type and support five query modes:
- **overlaps** -- find memories whose range overlaps a given range.
- **within** -- find memories whose range falls entirely within a given range.

You can combine temporal modes; every supplied predicate must match. For
example, `after: A` with `before: B` finds memories wholly between those
cutoffs. Contradictory predicates return no memories.

`before` and `after` are strict. A range ending at a point with an exclusive
upper bound is before that point, while a range beginning at or containing the
point is neither before nor after it. This makes `before: now` suitable for
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp/me_memory_export.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Prefer `path` to write directly to a file instead of returning content through t
| `overlaps` | `object \| null` | no | Find memories overlapping this range (`{start, end}`). |
| `within` | `object \| null` | no | Find memories fully within this range (`{start, end}`). |

You can provide multiple temporal fields; all populated predicates must match.

## Returns

### When `path` is provided (file output)
Expand Down
2 changes: 2 additions & 0 deletions docs/mcp/me_memory_search.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ See [Tree filter syntax](../concepts.md#tree-filter-syntax) for the full referen
| `overlaps` | `object \| null` | no | Find memories overlapping this range (`{start, end}`). |
| `within` | `object \| null` | no | Find memories fully within this range (`{start, end}`). |

You can provide multiple temporal fields; all populated predicates must match.

`before` and `after` are strict and exclude memories without a temporal range.
A half-open range ending exactly at `before` matches; a range beginning at or
containing the point does not.
Expand Down
4 changes: 4 additions & 0 deletions docs/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ Filters narrow any search (and can be used alone to browse):
- `--temporal-contains` / `--temporal-overlaps` / `--temporal-within` — filter by containment or range relationships.
- `--grep <pattern>` — regex over content. It must accompany semantic/fulltext search or a tree, structured metadata (`--meta`), or temporal filter. `--meta-predicate` alone does not satisfy this guard because both filters can require broad scans.

Temporal flags can be combined; every supplied predicate must match. For
example, use `--temporal-after <start>` with `--temporal-before <end>` to find
memories wholly between two cutoffs.

## Thresholds and tuning

- **`--semantic-threshold <n>` (`semanticThreshold`)** — minimum cosine similarity, in `[0, 1]`. Higher is stricter (`0.8` ≈ strong matches; `0.5` ≈ loosely related). Values outside `[0, 1]` are rejected, not clamped. Applies only to the semantic/vector match.
Expand Down
10 changes: 5 additions & 5 deletions docs/typescript-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,11 +176,11 @@ const { results } = await me.memory.search({
metaPredicate: // PostgreSQL JSONPath Boolean predicate
'$.priority >= 3 && !exists($.archivedAt)',
temporal: { // time-based filter
contains: "2025-06-15T00:00:00Z", // point-in-time
// OR before: "2025-06-15T00:00:00Z"
// OR after: "2025-06-15T00:00:00Z"
// OR overlaps: { start, end }
// OR within: { start, end }
after: "2025-06-01T00:00:00Z",
before: "2025-07-01T00:00:00Z", // every populated mode must match
// contains: "2025-06-15T00:00:00Z"
// overlaps: { start, end }
// within: { start, end }
},

// Tuning
Expand Down
38 changes: 38 additions & 0 deletions packages/cli/commands/memory-projection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,44 @@ test("search and export reject empty metadata predicates before RPC", async () =
expect(requests).toHaveLength(0);
});

test("search and export preserve every temporal filter", async () => {
const requests = captureRpcResult({ results: [], total: 0, limit: 10 });
const flags = [
"--temporal-before",
"2026-02-01T00:00:00Z",
"--temporal-after",
"2026-01-01T00:00:00Z",
"--temporal-contains",
"2026-01-15T00:00:00Z",
"--temporal-overlaps",
"2026-01-10T00:00:00Z,2026-01-20T00:00:00Z",
"--temporal-within",
"2026-01-01T00:00:00Z,2026-02-01T00:00:00Z",
];
const temporal = {
before: "2026-02-01T00:00:00Z",
after: "2026-01-01T00:00:00Z",
contains: "2026-01-15T00:00:00Z",
overlaps: { start: "2026-01-10T00:00:00Z", end: "2026-01-20T00:00:00Z" },
within: { start: "2026-01-01T00:00:00Z", end: "2026-02-01T00:00:00Z" },
};

await program().parseAsync(["memory", "search", ...flags], {
from: "user",
});
await program().parseAsync(["memory", "export", ...flags], {
from: "user",
});

expect(requests.map(({ method, params }) => ({ method, params }))).toEqual([
{ method: "memory.search", params: { temporal, limit: 10 } },
{
method: "memory.search",
params: { temporal, limit: 1000, orderBy: "asc" },
},
]);
});

test("default text search projects locally and always displays the score", async () => {
const requests = captureRpcResult({
results: [
Expand Down
90 changes: 37 additions & 53 deletions packages/cli/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,40 @@ function parseMeta(value: string): Record<string, unknown> {
}
}

type TemporalOptions = {
temporalBefore?: string;
temporalAfter?: string;
temporalContains?: string;
temporalOverlaps?: string;
temporalWithin?: string;
};

/** Build conjunctive temporal filters from every supplied temporal option. */
function parseTemporalFilters(
opts: TemporalOptions,
fmt: ReturnType<typeof getOutputFormat>,
): Record<string, unknown> | null {
const temporal: Record<string, unknown> = {};
if (opts.temporalBefore) temporal.before = opts.temporalBefore;
if (opts.temporalAfter) temporal.after = opts.temporalAfter;
if (opts.temporalContains) temporal.contains = opts.temporalContains;
if (opts.temporalOverlaps) {
const parts = opts.temporalOverlaps.split(",").map((s) => s.trim());
if (parts.length !== 2 || !parts[0] || !parts[1]) {
handleError(new Error("--temporal-overlaps requires start,end"), fmt);
}
temporal.overlaps = { start: parts[0], end: parts[1] };
}
if (opts.temporalWithin) {
const parts = opts.temporalWithin.split(",").map((s) => s.trim());
if (parts.length !== 2 || !parts[0] || !parts[1]) {
handleError(new Error("--temporal-within requires start,end"), fmt);
}
temporal.within = { start: parts[0], end: parts[1] };
}
return Object.keys(temporal).length > 0 ? temporal : null;
}

export function parseMetaPredicate(value: string): string {
if (value.trim().length === 0) {
throw new Error("Invalid --meta-predicate: must not be empty");
Expand Down Expand Up @@ -444,31 +478,7 @@ function createMemorySearchCommand(): Command {
process.exit(1);
}

// Build temporal filter
let temporal: Record<string, unknown> | null = null;
if (opts.temporalBefore) {
temporal = { before: opts.temporalBefore };
} else if (opts.temporalAfter) {
temporal = { after: opts.temporalAfter };
} else if (opts.temporalContains) {
temporal = { contains: opts.temporalContains };
} else if (opts.temporalOverlaps) {
const parts = opts.temporalOverlaps
.split(",")
.map((s: string) => s.trim());
if (parts.length !== 2 || !parts[0] || !parts[1]) {
handleError(new Error("--temporal-overlaps requires start,end"), fmt);
}
temporal = { overlaps: { start: parts[0], end: parts[1] } };
} else if (opts.temporalWithin) {
const parts = opts.temporalWithin
.split(",")
.map((s: string) => s.trim());
if (parts.length !== 2 || !parts[0] || !parts[1]) {
handleError(new Error("--temporal-within requires start,end"), fmt);
}
temporal = { within: { start: parts[0], end: parts[1] } };
}
const temporal = parseTemporalFilters(opts, fmt);

// Build weights (only when both semantic + fulltext)
let weights: Record<string, number> | null = null;
Expand Down Expand Up @@ -1057,34 +1067,8 @@ function createMemoryExportCommand(): Command {
if (opts.meta) searchParams.meta = parseMeta(opts.meta);
if (opts.metaPredicate) searchParams.metaPredicate = opts.metaPredicate;

// Build temporal filter
if (opts.temporalBefore) {
searchParams.temporal = { before: opts.temporalBefore };
} else if (opts.temporalAfter) {
searchParams.temporal = { after: opts.temporalAfter };
} else if (opts.temporalContains) {
searchParams.temporal = { contains: opts.temporalContains };
} else if (opts.temporalOverlaps) {
const parts = opts.temporalOverlaps
.split(",")
.map((s: string) => s.trim());
if (parts.length !== 2 || !parts[0] || !parts[1]) {
handleError(new Error("--temporal-overlaps requires start,end"), fmt);
}
searchParams.temporal = {
overlaps: { start: parts[0], end: parts[1] },
};
} else if (opts.temporalWithin) {
const parts = opts.temporalWithin
.split(",")
.map((s: string) => s.trim());
if (parts.length !== 2 || !parts[0] || !parts[1]) {
handleError(new Error("--temporal-within requires start,end"), fmt);
}
searchParams.temporal = {
within: { start: parts[0], end: parts[1] },
};
}
const temporal = parseTemporalFilters(opts, fmt);
if (temporal) searchParams.temporal = temporal;

const client = buildMemoryClient(creds);

Expand Down
30 changes: 27 additions & 3 deletions packages/cli/mcp/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,7 +421,7 @@ test("memory search projects rows and keeps both compact JSON format names", asy
}
});

test("memory search and export forward temporal before/after filters", async () => {
test("memory search and export forward every temporal filter", async () => {
const fullResult = {
results: [{ ...fullMemory, score: -1 }],
total: 1,
Expand All @@ -439,7 +439,19 @@ test("memory search and export forward temporal before/after filters", async ()
await client.callTool({
name: "me_memory_export",
arguments: {
temporal: { after: "2026-08-09T12:00:00Z" },
temporal: {
before: "2026-08-09T12:00:00Z",
after: "2026-08-01T12:00:00Z",
contains: "2026-08-05T12:00:00Z",
overlaps: {
start: "2026-08-04T00:00:00Z",
end: "2026-08-06T00:00:00Z",
},
within: {
start: "2026-08-01T00:00:00Z",
end: "2026-08-09T00:00:00Z",
},
},
format: "json",
},
});
Expand All @@ -451,7 +463,19 @@ test("memory search and export forward temporal before/after filters", async ()
{
method: "memory.search",
params: {
temporal: { after: "2026-08-09T12:00:00Z" },
temporal: {
before: "2026-08-09T12:00:00Z",
after: "2026-08-01T12:00:00Z",
contains: "2026-08-05T12:00:00Z",
overlaps: {
start: "2026-08-04T00:00:00Z",
end: "2026-08-06T00:00:00Z",
},
within: {
start: "2026-08-01T00:00:00Z",
end: "2026-08-09T00:00:00Z",
},
},
limit: 1000,
orderBy: "asc",
},
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,9 @@ Docs: ${docUrl("me_memory_search")}`,
})
.optional()
.nullable()
.describe("Temporal filter for search"),
.describe(
"Temporal filters for search; all populated predicates must match",
),
weights: z
.object({
fulltext: z
Expand Down Expand Up @@ -1283,7 +1285,7 @@ Docs: ${docUrl("me_memory_export")}`,
})
.optional()
.nullable()
.describe("Temporal filter"),
.describe("Temporal filters; all populated predicates must match"),
format: z.string().describe("Output format: json, yaml, or md"),
limit: z
.number()
Expand Down
17 changes: 15 additions & 2 deletions packages/database/space/migrate/idempotent/002_search.sql
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
-- _max_vec_dist -> _min_similarity (a param rename is a 42P13 just like a type
-- change). The fn block drops a stale-signatured definition before the create
-- (matching on arg types AND names) and asserts the new signature after.
{{fn search_memory(_tree_access jsonb, _bm25 bm25query, _vec halfvec, _min_similarity float8, _ltree ltree, _lquery lquery, _ltxtquery ltxtquery, _meta_contains jsonb, _temporal_within tstzrange, _temporal_overlaps tstzrange, _temporal_before timestamptz, _temporal_after timestamptz, _regexp text, _limit bigint, _order text, _meta_predicate jsonpath) returns table (id uuid, meta jsonb, tree ltree, temporal tstzrange, content text, name text, version bigint, version_hash text, has_embedding bool, created_at timestamptz, updated_at timestamptz, score float8)}}
{{fn search_memory(_tree_access jsonb, _bm25 bm25query, _vec halfvec, _min_similarity float8, _ltree ltree, _lquery lquery, _ltxtquery ltxtquery, _meta_contains jsonb, _temporal_within tstzrange, _temporal_overlaps tstzrange, _temporal_before timestamptz, _temporal_after timestamptz, _regexp text, _limit bigint, _order text, _meta_predicate jsonpath, _temporal_contains timestamptz) returns table (id uuid, meta jsonb, tree ltree, temporal tstzrange, content text, name text, version bigint, version_hash text, has_embedding bool, created_at timestamptz, updated_at timestamptz, score float8)}}
create or replace function {{schema}}.search_memory
( _tree_access jsonb
, _bm25 bm25query default null
Expand All @@ -24,6 +24,7 @@ create or replace function {{schema}}.search_memory
, _limit bigint default 10
, _order text default 'desc' -- unranked (filter-only) result order by id: 'desc' (newest first) | 'asc'
, _meta_predicate jsonpath default null
, _temporal_contains timestamptz default null
)
returns table
( id uuid
Expand Down Expand Up @@ -234,6 +235,15 @@ begin
);
end if;

-- temporal_contains
if _temporal_contains is not null then
_filter_count = _filter_count + 1;
_filters = array_append
( _filters
, format($sql$and m.temporal @> %L::timestamptz$sql$, _temporal_contains)
);
end if;

-- regexp
if _regexp is not null then
if _filter_count = 0 then
Expand Down Expand Up @@ -303,7 +313,7 @@ set search_path to pg_catalog, {{schema}}, public, pg_temp
-------------------------------------------------------------------------------
-- Same `name` return-column addition and _max_vec_dist -> _min_similarity param
-- rename as search_memory; same fn-block guard (drops the stale signature).
{{fn hybrid_search_memory(_tree_access jsonb, _bm25 bm25query, _vec halfvec, _min_similarity float8, _ltree ltree, _lquery lquery, _ltxtquery ltxtquery, _meta_contains jsonb, _temporal_within tstzrange, _temporal_overlaps tstzrange, _temporal_before timestamptz, _temporal_after timestamptz, _regexp text, _k float8, _candidate_limit bigint, _fulltext_weight float8, _semantic_weight float8, _limit bigint, _meta_predicate jsonpath) returns table(id uuid, meta jsonb, tree ltree, temporal tstzrange, content text, name text, version bigint, version_hash text, has_embedding bool, created_at timestamptz, updated_at timestamptz, score float8)}}
{{fn hybrid_search_memory(_tree_access jsonb, _bm25 bm25query, _vec halfvec, _min_similarity float8, _ltree ltree, _lquery lquery, _ltxtquery ltxtquery, _meta_contains jsonb, _temporal_within tstzrange, _temporal_overlaps tstzrange, _temporal_before timestamptz, _temporal_after timestamptz, _regexp text, _k float8, _candidate_limit bigint, _fulltext_weight float8, _semantic_weight float8, _limit bigint, _meta_predicate jsonpath, _temporal_contains timestamptz) returns table(id uuid, meta jsonb, tree ltree, temporal tstzrange, content text, name text, version bigint, version_hash text, has_embedding bool, created_at timestamptz, updated_at timestamptz, score float8)}}
create or replace function {{schema}}.hybrid_search_memory
( _tree_access jsonb
, _bm25 bm25query
Expand All @@ -324,6 +334,7 @@ create or replace function {{schema}}.hybrid_search_memory
, _semantic_weight float8 default 1.0
, _limit bigint default 10
, _meta_predicate jsonpath default null
, _temporal_contains timestamptz default null
)
returns table
( id uuid
Expand Down Expand Up @@ -394,6 +405,7 @@ begin
, _temporal_overlaps => _temporal_overlaps
, _temporal_before => _temporal_before
, _temporal_after => _temporal_after
, _temporal_contains => _temporal_contains
, _regexp => _regexp
, _limit => _candidate_limit
) m
Expand All @@ -416,6 +428,7 @@ begin
, _temporal_overlaps => _temporal_overlaps
, _temporal_before => _temporal_before
, _temporal_after => _temporal_after
, _temporal_contains => _temporal_contains
, _regexp => _regexp
, _limit => _candidate_limit
) m
Expand Down
Loading