From 807b0b354e58b389e0c38d735917cf2ae8340962 Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Sun, 9 Aug 2026 14:20:50 -0500 Subject: [PATCH 1/2] feat(search): combine temporal filters --- docs/cli/me-memory.md | 4 + docs/concepts.md | 4 + docs/mcp/me_memory_export.md | 2 + docs/mcp/me_memory_search.md | 2 + docs/search.md | 4 + docs/typescript-client.md | 10 +-- .../cli/commands/memory-projection.test.ts | 38 ++++++++ packages/cli/commands/memory.ts | 90 ++++++++----------- packages/cli/mcp/server.test.ts | 30 ++++++- packages/cli/mcp/server.ts | 6 +- .../space/migrate/idempotent/002_search.sql | 17 +++- .../space/migrate/migrate.integration.test.ts | 41 ++++----- packages/database/space/version.ts | 8 +- packages/engine/space/db.integration.test.ts | 35 ++++++++ packages/engine/space/db.ts | 16 ++-- packages/engine/space/types.ts | 2 + packages/protocol/memory.test.ts | 17 ++++ .../rpc/memory/memory.integration.test.ts | 42 +++++++++ packages/server/rpc/memory/memory.ts | 34 +++---- 19 files changed, 280 insertions(+), 122 deletions(-) diff --git a/docs/cli/me-memory.md b/docs/cli/me-memory.md index 6cf03751..a9125790 100644 --- a/docs/cli/me-memory.md +++ b/docs/cli/me-memory.md @@ -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 ` with `--temporal-before ` 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: diff --git a/docs/concepts.md b/docs/concepts.md index 7158ca1d..4e0dfe19 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -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 diff --git a/docs/mcp/me_memory_export.md b/docs/mcp/me_memory_export.md index b4d5d8c4..5fb3b328 100644 --- a/docs/mcp/me_memory_export.md +++ b/docs/mcp/me_memory_export.md @@ -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) diff --git a/docs/mcp/me_memory_search.md b/docs/mcp/me_memory_search.md index 859e4499..195371ee 100644 --- a/docs/mcp/me_memory_search.md +++ b/docs/mcp/me_memory_search.md @@ -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. diff --git a/docs/search.md b/docs/search.md index 0dcaa41e..cc7e96ac 100644 --- a/docs/search.md +++ b/docs/search.md @@ -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 ` — 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 ` with `--temporal-before ` to find +memories wholly between two cutoffs. + ## Thresholds and tuning - **`--semantic-threshold ` (`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. diff --git a/docs/typescript-client.md b/docs/typescript-client.md index 21f62f8b..15ed7f13 100644 --- a/docs/typescript-client.md +++ b/docs/typescript-client.md @@ -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 diff --git a/packages/cli/commands/memory-projection.test.ts b/packages/cli/commands/memory-projection.test.ts index e79e20d2..1c94cb57 100644 --- a/packages/cli/commands/memory-projection.test.ts +++ b/packages/cli/commands/memory-projection.test.ts @@ -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: [ diff --git a/packages/cli/commands/memory.ts b/packages/cli/commands/memory.ts index 78fc2a21..3bb3f9f1 100644 --- a/packages/cli/commands/memory.ts +++ b/packages/cli/commands/memory.ts @@ -64,6 +64,40 @@ function parseMeta(value: string): Record { } } +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, +): Record | null { + const temporal: Record = {}; + 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"); @@ -444,31 +478,7 @@ function createMemorySearchCommand(): Command { process.exit(1); } - // Build temporal filter - let temporal: Record | 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 | null = null; @@ -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); diff --git a/packages/cli/mcp/server.test.ts b/packages/cli/mcp/server.test.ts index 035a4af3..c4762876 100644 --- a/packages/cli/mcp/server.test.ts +++ b/packages/cli/mcp/server.test.ts @@ -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, @@ -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", }, }); @@ -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", }, diff --git a/packages/cli/mcp/server.ts b/packages/cli/mcp/server.ts index 78afd90f..9c889bde 100644 --- a/packages/cli/mcp/server.ts +++ b/packages/cli/mcp/server.ts @@ -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 @@ -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() diff --git a/packages/database/space/migrate/idempotent/002_search.sql b/packages/database/space/migrate/idempotent/002_search.sql index 4665b100..c7667dcf 100644 --- a/packages/database/space/migrate/idempotent/002_search.sql +++ b/packages/database/space/migrate/idempotent/002_search.sql @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/packages/database/space/migrate/migrate.integration.test.ts b/packages/database/space/migrate/migrate.integration.test.ts index f2047335..238f2fbd 100644 --- a/packages/database/space/migrate/migrate.integration.test.ts +++ b/packages/database/space/migrate/migrate.integration.test.ts @@ -303,13 +303,13 @@ describe("migration behavior", () => { }); }); - test("upgrades the 0.0.6 search signatures without breaking old positional calls", async () => { + test("upgrades the 0.0.7 search signatures", async () => { await withTestSpace(sql, {}, async (space) => { await sql.unsafe( - `drop function ${space.schema}.hybrid_search_memory(jsonb, bm25query, halfvec, float8, ltree, lquery, ltxtquery, jsonb, tstzrange, tstzrange, timestamptz, timestamptz, text, float8, bigint, float8, float8, bigint, jsonpath)`, + `drop function ${space.schema}.hybrid_search_memory(jsonb, bm25query, halfvec, float8, ltree, lquery, ltxtquery, jsonb, tstzrange, tstzrange, timestamptz, timestamptz, text, float8, bigint, float8, float8, bigint, jsonpath, timestamptz)`, ); await sql.unsafe( - `drop function ${space.schema}.search_memory(jsonb, bm25query, halfvec, float8, ltree, lquery, ltxtquery, jsonb, tstzrange, tstzrange, timestamptz, timestamptz, text, bigint, text, jsonpath)`, + `drop function ${space.schema}.search_memory(jsonb, bm25query, halfvec, float8, ltree, lquery, ltxtquery, jsonb, tstzrange, tstzrange, timestamptz, timestamptz, text, bigint, text, jsonpath, timestamptz)`, ); const resultColumns = ` @@ -346,9 +346,10 @@ describe("migration behavior", () => { , _temporal_overlaps tstzrange , _temporal_before timestamptz , _temporal_after timestamptz - , _regexp text - , _limit bigint - , _order text + , _regexp text + , _limit bigint + , _order text + , _meta_predicate jsonpath ) returns table (${resultColumns}) as $function$ ${emptyBody} $function$ language sql`); await sql.unsafe(` @@ -369,11 +370,12 @@ describe("migration behavior", () => { , _k float8 , _candidate_limit bigint , _fulltext_weight float8 - , _semantic_weight float8 - , _limit bigint + , _semantic_weight float8 + , _limit bigint + , _meta_predicate jsonpath ) returns table (${resultColumns}) as $function$ ${emptyBody} $function$ language sql`); - await sql.unsafe(`update ${space.schema}.version set version = '0.0.6'`); + await sql.unsafe(`update ${space.schema}.version set version = '0.0.7'`); await migrateSpace(sql, { slug: space.slug, schema: space.schema }); @@ -391,8 +393,8 @@ describe("migration behavior", () => { arg_count: Number(row.arg_count), })), ).toEqual([ - { proname: "hybrid_search_memory", arg_count: 19 }, - { proname: "search_memory", arg_count: 16 }, + { proname: "hybrid_search_memory", arg_count: 20 }, + { proname: "search_memory", arg_count: 17 }, ]); expect(await getSchemaVersion(sql, space.schema)).toBe( SPACE_SCHEMA_VERSION, @@ -404,24 +406,11 @@ describe("migration behavior", () => { '[]'::jsonb, null::bm25query, null::halfvec, null, null::ltree, null::lquery, null::ltxtquery, null::jsonb, null::tstzrange, null::tstzrange, null::timestamptz, - null::timestamptz, null::text, 10::bigint, 'desc'::text + null::timestamptz, null::text, 10::bigint, 'desc'::text, + null::jsonpath )`, ); expect(searched?.count).toBe(0); - - const [hybrid] = await sql.unsafe( - `select count(*)::int as count - from ${space.schema}.hybrid_search_memory( - '[]'::jsonb, - to_bm25query('upgrade', '${space.schema}.memory_content_bm25_idx'), - ('[' || repeat('0,', 1535) || '0]')::halfvec, - null, null::ltree, null::lquery, null::ltxtquery, null::jsonb, - null::tstzrange, null::tstzrange, null::timestamptz, - null::timestamptz, null::text, 60.0, 10::bigint, 1.0, 1.0, - 10::bigint - )`, - ); - expect(hybrid?.count).toBe(0); }); }); diff --git a/packages/database/space/version.ts b/packages/database/space/version.ts index 88d43765..742a9928 100644 --- a/packages/database/space/version.ts +++ b/packages/database/space/version.ts @@ -1,9 +1,9 @@ -// 0.0.7: adds the metaPredicate jsonpath argument to search_memory and +// 0.0.8: adds the temporalContains timestamptz argument to search_memory and // hybrid_search_memory (idempotent 002_search.sql). // Idempotent migrations currently re-run on every migrate pass (the // equal-version early-return in migrate/kit.ts is deliberately commented // out), so the bump is not what delivers the function. It marks the schema -// change and arms the ACTIVE downgrade guard: an older application (≤0.0.6, +// change and arms the ACTIVE downgrade guard: an older application (≤0.0.7, // which doesn't know these arguments) refuses to migrate a database stamped -// 0.0.7 instead of quietly re-running its older idempotent set against it. -export const SPACE_SCHEMA_VERSION = "0.0.7"; +// 0.0.8 instead of quietly re-running its older idempotent set against it. +export const SPACE_SCHEMA_VERSION = "0.0.8"; diff --git a/packages/engine/space/db.integration.test.ts b/packages/engine/space/db.integration.test.ts index 22b78967..d144401f 100644 --- a/packages/engine/space/db.integration.test.ts +++ b/packages/engine/space/db.integration.test.ts @@ -273,6 +273,41 @@ test("unranked (filter-only) search orders by id, newest-first by default", asyn expect(desc.map((r) => r.id)).toEqual([...ids].reverse()); }); +test("temporal filters are conjunctive, including contains and overlaps", async () => { + const target = await mustCreate(FULL, { + tree: "work.temporal.target", + content: "temporal target", + temporal: "[2026-01-10T00:00:00Z,2026-01-20T00:00:00Z)", + }); + await mustCreate(FULL, { + tree: "work.temporal.decoy", + content: "temporal decoy", + temporal: "[2026-01-10T00:00:00Z,2026-01-15T00:00:00Z)", + }); + await setEmbedding(target, [0, 0, 0, 1]); + + const filters = { + temporalAfter: "2026-01-05T00:00:00Z", + temporalBefore: "2026-01-25T00:00:00Z", + temporalContains: "2026-01-14T00:00:00Z", + temporalOverlaps: "[2026-01-15T00:00:00Z,2026-01-25T00:00:00Z)", + temporalWithin: "[2026-01-01T00:00:00Z,2026-02-01T00:00:00Z)", + }; + expect( + (await db.search(FULL, { ...filters, limit: 10 })).map((r) => r.id), + ).toEqual([target]); + expect( + ( + await db.hybridSearch(FULL, { + ...filters, + bm25: "target", + vec: [0, 0, 0, 1], + limit: 10, + }) + ).map((r) => r.id), + ).toEqual([target]); +}); + test("vector search ranks by embedding similarity and returns cosine similarity as score", async () => { const near = await mustCreate(FULL, { tree: "work.v1", diff --git a/packages/engine/space/db.ts b/packages/engine/space/db.ts index 6f266fbd..6b4d59b6 100644 --- a/packages/engine/space/db.ts +++ b/packages/engine/space/db.ts @@ -333,10 +333,11 @@ export function spaceStore(sql: Sql, schema: string): SpaceStore { ${o.temporalOverlaps ?? null}::tstzrange, ${o.temporalBefore ?? null}::timestamptz, ${o.temporalAfter ?? null}::timestamptz, - ${o.regexp ?? null}, - ${o.limit ?? 10}, - ${o.order ?? "desc"}, - ${o.metaPredicate ?? null}::jsonpath + ${o.regexp ?? null}, + ${o.limit ?? 10}, + ${o.order ?? "desc"}, + ${o.metaPredicate ?? null}::jsonpath, + ${o.temporalContains ?? null}::timestamptz )`; return rows.map(mapSearchItem); }, @@ -363,9 +364,10 @@ export function spaceStore(sql: Sql, schema: string): SpaceStore { ${o.k ?? 60.0}, ${o.candidateLimit ?? 30}, ${o.fulltextWeight ?? 1.0}, - ${o.semanticWeight ?? 1.0}, - ${o.limit ?? 10}, - ${o.metaPredicate ?? null}::jsonpath + ${o.semanticWeight ?? 1.0}, + ${o.limit ?? 10}, + ${o.metaPredicate ?? null}::jsonpath, + ${o.temporalContains ?? null}::timestamptz )`; return rows.map(mapSearchItem); }, diff --git a/packages/engine/space/types.ts b/packages/engine/space/types.ts index b67a83f4..be6397e6 100644 --- a/packages/engine/space/types.ts +++ b/packages/engine/space/types.ts @@ -82,6 +82,8 @@ export interface MemoryFilters { temporalOverlaps?: TemporalRange; temporalBefore?: string; temporalAfter?: string; + /** Point that the memory's temporal range must contain. */ + temporalContains?: string; /** case-insensitive regexp on content (must be combined with another filter). */ regexp?: string; } diff --git a/packages/protocol/memory.test.ts b/packages/protocol/memory.test.ts index 683ea8a0..36f7ad76 100644 --- a/packages/protocol/memory.test.ts +++ b/packages/protocol/memory.test.ts @@ -100,6 +100,23 @@ describe("temporalFilterSchema", () => { temporalFilterSchema.safeParse({ before: "2026-08-09T12:00:00" }).success, ).toBe(false); }); + + test("preserves multiple temporal predicates", () => { + const 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", + }, + }; + expect(temporalFilterSchema.parse(temporal)).toEqual(temporal); + }); }); describe("memorySearchParams", () => { diff --git a/packages/server/rpc/memory/memory.integration.test.ts b/packages/server/rpc/memory/memory.integration.test.ts index 0fdd7749..53d14004 100644 --- a/packages/server/rpc/memory/memory.integration.test.ts +++ b/packages/server/rpc/memory/memory.integration.test.ts @@ -821,6 +821,48 @@ test("search: temporal before/after are strict at the cutoff", async () => { expect(after.results.map((r) => r.content)).toEqual(["future"]); }); +test("search: combines all temporal predicates with AND", async () => { + await call("memory.batchCreate", { + memories: [ + { + content: "temporal target", + tree: "share.temporal.conjunction", + temporal: { + start: "2026-01-10T00:00:00Z", + end: "2026-01-20T00:00:00Z", + }, + }, + { + content: "temporal decoy", + tree: "share.temporal.conjunction", + temporal: { + start: "2026-01-10T00:00:00Z", + end: "2026-01-15T00:00:00Z", + }, + }, + ], + }); + + const res = await call<{ results: { content: string }[] }>("memory.search", { + tree: "share.temporal.conjunction", + temporal: { + after: "2026-01-05T00:00:00Z", + before: "2026-01-25T00:00:00Z", + contains: "2026-01-14T00:00:00Z", + overlaps: { + start: "2026-01-15T00:00:00Z", + end: "2026-01-25T00:00:00Z", + }, + within: { + start: "2026-01-01T00:00:00Z", + end: "2026-02-01T00:00:00Z", + }, + }, + limit: 1000, + }); + expect(res.results.map((r) => r.content)).toEqual(["temporal target"]); +}); + test("search: metaPredicate adds JSONPath predicates to metadata containment", async () => { await call("memory.batchCreate", { memories: [ diff --git a/packages/server/rpc/memory/memory.ts b/packages/server/rpc/memory/memory.ts index 161bc9b2..2db887b2 100644 --- a/packages/server/rpc/memory/memory.ts +++ b/packages/server/rpc/memory/memory.ts @@ -231,34 +231,28 @@ function toMemoryResponse( } /** - * Map the wire temporal filter (before | after | contains | overlaps | within - * — mutually exclusive) onto the space search's temporal range params. A - * `contains` point becomes an inclusive point-range overlap (true iff the - * memory's range spans the instant). + * Map every populated wire temporal filter onto the space search's temporal + * params. The database combines predicates with AND. */ function mapTemporalFilter(tf: MemorySearchParams["temporal"]): { temporalWithin?: string; temporalOverlaps?: string; temporalBefore?: string; temporalAfter?: string; + temporalContains?: string; } { if (!tf) return {}; - if (tf.within) { - return { temporalWithin: `[${tf.within.start},${tf.within.end})` }; - } - if (tf.overlaps) { - return { temporalOverlaps: `[${tf.overlaps.start},${tf.overlaps.end})` }; - } - if (tf.contains) { - return { temporalOverlaps: `[${tf.contains},${tf.contains}]` }; - } - if (tf.before) { - return { temporalBefore: tf.before }; - } - if (tf.after) { - return { temporalAfter: tf.after }; - } - return {}; + return { + temporalWithin: tf.within + ? `[${tf.within.start},${tf.within.end})` + : undefined, + temporalOverlaps: tf.overlaps + ? `[${tf.overlaps.start},${tf.overlaps.end})` + : undefined, + temporalBefore: tf.before, + temporalAfter: tf.after, + temporalContains: tf.contains, + }; } // ============================================================================= From 380dea78542e28bd8415f5a14775ea74ebf6d45b Mon Sep 17 00:00:00 2001 From: John Pruitt Date: Sun, 9 Aug 2026 14:28:38 -0500 Subject: [PATCH 2/2] fix(search): reject empty temporal filters --- packages/protocol/fields.ts | 41 +++++++++++++++++++------------- packages/protocol/memory.test.ts | 5 ++++ 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/packages/protocol/fields.ts b/packages/protocol/fields.ts index 9c873922..cdfaa3d4 100644 --- a/packages/protocol/fields.ts +++ b/packages/protocol/fields.ts @@ -140,23 +140,30 @@ export const temporalSchema = z.object({ /** * Temporal filter for search. */ -export const temporalFilterSchema = z.object({ - before: timestampSchema.optional(), - after: timestampSchema.optional(), - contains: timestampSchema.optional(), - overlaps: z - .object({ - start: timestampSchema, - end: timestampSchema, - }) - .optional(), - within: z - .object({ - start: timestampSchema, - end: timestampSchema, - }) - .optional(), -}); +export const temporalFilterSchema = z + .object({ + before: timestampSchema.optional(), + after: timestampSchema.optional(), + contains: timestampSchema.optional(), + overlaps: z + .object({ + start: timestampSchema, + end: timestampSchema, + }) + .optional(), + within: z + .object({ + start: timestampSchema, + end: timestampSchema, + }) + .optional(), + }) + .refine( + (filter) => Object.values(filter).some((value) => value !== undefined), + { + message: "temporal filter requires at least one predicate", + }, + ); /** * Metadata schema (arbitrary JSON object). diff --git a/packages/protocol/memory.test.ts b/packages/protocol/memory.test.ts index 36f7ad76..66bfdaca 100644 --- a/packages/protocol/memory.test.ts +++ b/packages/protocol/memory.test.ts @@ -117,6 +117,11 @@ describe("temporalFilterSchema", () => { }; expect(temporalFilterSchema.parse(temporal)).toEqual(temporal); }); + + test("rejects an empty filter", () => { + expect(temporalFilterSchema.safeParse({}).success).toBe(false); + expect(memorySearchParams.safeParse({ temporal: {} }).success).toBe(false); + }); }); describe("memorySearchParams", () => {