diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fde71d9..2772a969 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- **Time-range filtering on `memory_recall`, `memory_smart_search`, and `memory_sessions`** ([#392](https://github.com/rohitg00/agentmemory/issues/392)). All three tools now accept optional ISO 8601 `start_time` / `end_time` arguments (both inclusive) so agents can answer "what did I work on last week?" without keyword guessing. `memory_sessions` also gains a `limit` parameter (default 50, max 1000) and now sorts by `startedAt` descending. Sessions are matched by lifetime overlap with the window — a session that started Apr 30 and ended May 2 still matches `2026-05-01..2026-05-07`; active sessions (no `endedAt`) are treated as still running. Bad input (unparseable date or `start_time > end_time`) returns a 400 with `code: invalid_time_range` *before* retrieval runs. + - REST: `POST /agentmemory/search`, `POST /agentmemory/smart-search` accept `start_time` / `end_time` in the body. `GET /agentmemory/sessions` accepts `?start_time=&end_time=&limit=`. + - MCP server: `memory_recall`, `memory_smart_search`, `memory_sessions` schemas extended; the same arguments are forwarded to `mem::search` / `mem::smart-search`. + - MCP shim (`@agentmemory/mcp`): proxy mode forwards the new arguments to the running server; the local `InMemoryKV` fallback applies the filter against `memory.createdAt` (recall) and `session.startedAt` / `endedAt` (sessions). + - Search/recall over-fetches (10× in `mem::search`, 5× in `HybridSearch`) and bumps the diversify-by-session cap to 5/session when a time range is set, so recall@K does not collapse on narrow windows. `mem::search` filtering by `project` / `cwd` is unchanged. + ## [0.9.15] — 2026-05-15 DevEx overhaul. Four PRs landed simultaneously rebuilding the first-run experience to SkillKit-grade polish: splash banner + interactive agent grid + provider picker + smart-defaults preferences, `agentmemory connect ` to automate native-plugin install for 8 agents, interactive `doctor` v2 with Fix/Skip/More/Quit prompts and a `--all` auto-fix flag, `agentmemory remove` for clean uninstall with destruction-plan confirmation, plus five silent-killer fixes around viewer port collisions, engine version-mismatch detection, `stop --force` override, adopt-on-attach state recording, and an npx-to-global-install hint. diff --git a/README.md b/README.md index 245dad27..c4d94467 100644 --- a/README.md +++ b/README.md @@ -708,6 +708,22 @@ Fused with Reciprocal Rank Fusion (RRF, k=60) and session-diversified (max 3 res BM25 tokenizes Greek, Cyrillic, Hebrew, Arabic, and accented Latin out of the box. For Chinese / Japanese / Korean memories, install the optional segmenters (`npm install @node-rs/jieba tiny-segmenter`) to split CJK runs into word-level tokens; without them, agentmemory soft-falls to whole-run tokenization and prints a one-time hint on stderr. +### Time-range filtering + +`memory_recall`, `memory_smart_search`, and `memory_sessions` all accept optional ISO 8601 `start_time` / `end_time` bounds (both inclusive). Use them to answer "what did I work on last week?" without falling back to keyword guessing. + +```bash +# All sessions whose lifetime overlapped May 1–7 +curl -s "http://localhost:3111/agentmemory/sessions?start_time=2026-05-01T00:00:00Z&end_time=2026-05-07T23:59:59Z&limit=50" | jq + +# Recall against a specific window +curl -s -X POST http://localhost:3111/agentmemory/smart-search \ + -H "content-type: application/json" \ + -d '{"query":"auth refactor","start_time":"2026-05-01T00:00:00Z","end_time":"2026-05-07T23:59:59Z"}' +``` + +The same arguments work via MCP tool calls (`memory_smart_search`, `memory_recall`, `memory_sessions`). For sessions, the filter checks lifetime overlap with the window — a session that started April 30 and ended May 2 still matches `2026-05-01..2026-05-07`. Active (no `endedAt`) sessions are treated as still running. Bad input (unparseable date or `start_time > end_time`) is rejected with a 400 / `code: invalid_time_range` before the search runs, so you don't pay for retrieval on a malformed window. + ### Embedding providers agentmemory auto-detects your provider. For best results, install local embeddings (free): @@ -740,13 +756,13 @@ npm install @xenova/transformers | Tool | Description | |------|-------------| -| `memory_recall` | Search past observations | +| `memory_recall` | Search past observations (optional `start_time` / `end_time` for ISO 8601 time-range filter) | | `memory_compress_file` | Compress markdown files while preserving structure | | `memory_save` | Save an insight, decision, or pattern | | `memory_patterns` | Detect recurring patterns | -| `memory_smart_search` | Hybrid semantic + keyword search | +| `memory_smart_search` | Hybrid semantic + keyword search (optional `start_time` / `end_time` time-range filter) | | `memory_file_history` | Past observations about specific files | -| `memory_sessions` | List recent sessions | +| `memory_sessions` | List recent sessions (optional `start_time` / `end_time` / `limit` — sessions whose lifetime overlaps the window, most-recent first) | | `memory_timeline` | Chronological observations | | `memory_profile` | Project profile (concepts, files, patterns) | | `memory_export` | Export all memory data | @@ -1094,8 +1110,10 @@ Create `~/.agentmemory/.env`: | `GET` | `/agentmemory/health` | Health check (always public) | | `POST` | `/agentmemory/session/start` | Start session + get context | | `POST` | `/agentmemory/session/end` | End session | +| `GET` | `/agentmemory/sessions` | List sessions (`?start_time=&end_time=&limit=` — ISO 8601 time-range filter, lifetime overlap, most-recent first) | | `POST` | `/agentmemory/observe` | Capture observation | -| `POST` | `/agentmemory/smart-search` | Hybrid search | +| `POST` | `/agentmemory/search` | Keyword search (BM25); accepts `start_time` / `end_time` for ISO 8601 time-range filter | +| `POST` | `/agentmemory/smart-search` | Hybrid search; accepts `start_time` / `end_time` for ISO 8601 time-range filter | | `POST` | `/agentmemory/context` | Generate context | | `POST` | `/agentmemory/remember` | Save to long-term memory | | `POST` | `/agentmemory/forget` | Delete observations | diff --git a/src/functions/search.ts b/src/functions/search.ts index 74af9ff1..5250e8a6 100644 --- a/src/functions/search.ts +++ b/src/functions/search.ts @@ -7,6 +7,7 @@ import { VectorIndex } from '../state/vector-index.js' import type { EmbeddingProvider } from '../types.js' import { memoryToObservation } from '../state/memory-utils.js' import { recordAccessBatch } from './access-tracker.js' +import { inTimeRange, parseTimeRange, TimeRangeError } from '../state/time-filter.js' import { logger } from "../logger.js"; let index: SearchIndex | null = null @@ -171,6 +172,8 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { cwd?: string format?: string token_budget?: number + start_time?: string + end_time?: string }) => { const idx = getSearchIndex() @@ -201,14 +204,32 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { tokenBudget = data.token_budget } + // Optional time-range filter (issue #392). Mirrors the project/cwd + // filter pattern below: we over-fetch from the index when active and + // drop out-of-range observations in the candidate pass so recall@K + // doesn't collapse on narrow windows. + let timeRange: ReturnType + try { + timeRange = parseTimeRange({ + start_time: data.start_time, + end_time: data.end_time, + }) + } catch (err) { + if (err instanceof TimeRangeError) { + throw new Error(`mem::search: ${err.message}`) + } + throw err + } + if (idx.size === 0) { const count = await rebuildIndex(kv) logger.info('Search index rebuilt', { entries: count }) } - // When filtering by project/cwd, over-fetch from the index so the - // post-filter still has a chance of returning `effectiveLimit` results. - const filtering = !!(projectFilter || cwdFilter) + // When filtering by project/cwd or a time range, over-fetch from the + // index so the post-filter still has a chance of returning + // `effectiveLimit` results. + const filtering = !!(projectFilter || cwdFilter || timeRange) const fetchLimit = filtering ? Math.max(effectiveLimit * 10, 100) : effectiveLimit const results = idx.search(query, fetchLimit) @@ -223,9 +244,16 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { // First pass: filter by session (sequential — benefits from session cache). const candidates: typeof results = [] + // When a time range is active we cannot decide membership at the + // candidate stage (timestamp lives on the observation, not the + // BM25 index entry), so collect a larger pool here and let the + // post-enrichment filter trim it. + const candidateCap = timeRange + ? Math.max(effectiveLimit * 10, 100) + : effectiveLimit for (const r of results) { - if (candidates.length >= effectiveLimit) break - if (filtering) { + if (candidates.length >= candidateCap) break + if (projectFilter || cwdFilter) { const s = await loadSession(r.sessionId) if (!s) continue if (projectFilter && s.project !== projectFilter) continue @@ -253,13 +281,14 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { const enriched: SearchResult[] = [] for (let i = 0; i < candidates.length; i++) { const obs = obsResults[i] - if (obs) { - enriched.push({ - observation: obs, - score: candidates[i].score, - sessionId: candidates[i].sessionId, - }) - } + if (!obs) continue + if (timeRange && !inTimeRange(obs.timestamp, timeRange)) continue + enriched.push({ + observation: obs, + score: candidates[i].score, + sessionId: candidates[i].sessionId, + }) + if (enriched.length >= effectiveLimit) break } void recordAccessBatch( @@ -339,6 +368,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void { results: packed.items.length, hasProjectFilter: !!projectFilter, hasCwdFilter: !!cwdFilter, + hasTimeRange: !!timeRange, }) return { format, diff --git a/src/functions/smart-search.ts b/src/functions/smart-search.ts index fdeed273..e3abc1f0 100644 --- a/src/functions/smart-search.ts +++ b/src/functions/smart-search.ts @@ -7,18 +7,29 @@ import type { import { KV } from "../state/schema.js"; import { StateKV } from "../state/kv.js"; import { recordAccessBatch } from "./access-tracker.js"; +import { + parseTimeRange, + TimeRangeError, + type TimeRange, +} from "../state/time-filter.js"; import { logger } from "../logger.js"; export function registerSmartSearchFunction( sdk: ISdk, kv: StateKV, - searchFn: (query: string, limit: number) => Promise, + searchFn: ( + query: string, + limit: number, + options?: { timeRange?: TimeRange | null }, + ) => Promise, ): void { sdk.registerFunction("mem::smart-search", async (data: { query?: string; expandIds?: Array; limit?: number; + start_time?: string; + end_time?: string; }) => { if (data.expandIds && data.expandIds.length > 0) { @@ -67,8 +78,21 @@ export function registerSmartSearchFunction( return { mode: "compact", results: [], error: "query is required" }; } + let timeRange: TimeRange | null; + try { + timeRange = parseTimeRange({ + start_time: data.start_time, + end_time: data.end_time, + }); + } catch (err) { + if (err instanceof TimeRangeError) { + return { mode: "compact", results: [], error: err.message }; + } + throw err; + } + const limit = Math.max(1, Math.min(data.limit ?? 20, 100)); - const hybridResults = await searchFn(data.query, limit); + const hybridResults = await searchFn(data.query, limit, { timeRange }); const compact: CompactSearchResult[] = hybridResults.map((r) => ({ obsId: r.observation.id, @@ -87,6 +111,7 @@ export function registerSmartSearchFunction( logger.info("Smart search compact", { query: data.query, results: compact.length, + hasTimeRange: !!timeRange, }); return { mode: "compact", results: compact }; }, diff --git a/src/index.ts b/src/index.ts index c662643f..d273f548 100644 --- a/src/index.ts +++ b/src/index.ts @@ -320,8 +320,8 @@ async function main() { graphWeight, ); - registerSmartSearchFunction(sdk, kv, (query, limit) => - hybridSearch.search(query, limit), + registerSmartSearchFunction(sdk, kv, (query, limit, options) => + hybridSearch.search(query, limit, options), ); registerApiTriggers(sdk, kv, secret, metricsStore, provider); diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 21c2069a..734580b6 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -10,6 +10,11 @@ import type { } from "../types.js"; import { getVisibleTools } from "./tools-registry.js"; import { timingSafeCompare } from "../auth.js"; +import { + filterSessionsByTime, + parseTimeRange, + TimeRangeError, +} from "../state/time-filter.js"; type McpResponse = { status_code: number; @@ -112,11 +117,25 @@ export function registerMcpEndpoints( body: { error: "token_budget must be a positive integer" }, }; } + // Validate time range up front for a clean 400 (issue #392). + try { + parseTimeRange({ + start_time: args.start_time, + end_time: args.end_time, + }); + } catch (err) { + if (err instanceof TimeRangeError) { + return { status_code: 400, body: { error: err.message, code: err.code } }; + } + throw err; + } const result = await sdk.trigger({ function_id: "mem::search", payload: { query: args.query, limit: typeof args.limit === "number" ? args.limit : 10, format, token_budget: tokenBudget, + start_time: asNonEmptyString(args.start_time), + end_time: asNonEmptyString(args.end_time), } }); const text = format === "narrative" && @@ -237,15 +256,39 @@ export function registerMcpEndpoints( } case "memory_sessions": { - const sessions = await kv.list(KV.sessions); - return { - status_code: 200, - body: { - content: [ - { type: "text", text: JSON.stringify({ sessions }, null, 2) }, - ], - }, - }; + try { + const timeRange = parseTimeRange({ + start_time: args.start_time, + end_time: args.end_time, + }); + let limit = 50; + if (args.limit !== undefined) { + const parsed = asNumber(args.limit); + if (parsed === undefined || !Number.isInteger(parsed) || parsed < 1) { + return { + status_code: 400, + body: { error: "limit must be a positive integer" }, + }; + } + limit = Math.min(parsed, 1000); + } + let sessions = await kv.list(KV.sessions); + sessions = filterSessionsByTime(sessions, timeRange); + sessions = sessions.slice(0, limit); + return { + status_code: 200, + body: { + content: [ + { type: "text", text: JSON.stringify({ sessions }, null, 2) }, + ], + }, + }; + } catch (err) { + if (err instanceof TimeRangeError) { + return { status_code: 400, body: { error: err.message, code: err.code } }; + } + throw err; + } } case "memory_smart_search": { @@ -257,12 +300,25 @@ export function registerMcpEndpoints( } const expandIds = parseCsvList(args.expandIds).slice(0, 20); const limit = Math.max(1, Math.min(100, asNumber(args.limit, 10) ?? 10)); + try { + parseTimeRange({ + start_time: args.start_time, + end_time: args.end_time, + }); + } catch (err) { + if (err instanceof TimeRangeError) { + return { status_code: 400, body: { error: err.message, code: err.code } }; + } + throw err; + } const result = await sdk.trigger({ function_id: "mem::smart-search", payload: { query: args.query, expandIds, limit, + start_time: asNonEmptyString(args.start_time), + end_time: asNonEmptyString(args.end_time), }, }); return { diff --git a/src/mcp/standalone.ts b/src/mcp/standalone.ts index ae1f4f36..0e8ba0b7 100644 --- a/src/mcp/standalone.ts +++ b/src/mcp/standalone.ts @@ -12,6 +12,13 @@ import { type Handle, type ProxyHandle, } from "./rest-proxy.js"; +import { + filterSessionsByTime, + inTimeRange, + parseTimeRange, + TimeRangeError, + type TimeRange, +} from "../state/time-filter.js"; const IMPLEMENTED_TOOLS = new Set([ "memory_save", @@ -64,11 +71,13 @@ function normalizeList(value: unknown): string[] { const DEFAULT_LIMIT = 10; const MAX_LIMIT = 100; -function parseLimit(raw: unknown, fallback = DEFAULT_LIMIT): number { +const DEFAULT_SESSION_LIMIT = 50; +const MAX_SESSION_LIMIT = 1000; +function parseLimit(raw: unknown, fallback = DEFAULT_LIMIT, max = MAX_LIMIT): number { if (typeof raw !== "number" && typeof raw !== "string") return fallback; const n = Number(raw); if (!Number.isFinite(n) || n <= 0) return fallback; - return Math.min(Math.floor(n), MAX_LIMIT); + return Math.min(Math.floor(n), max); } function textResponse(payload: unknown, pretty = false): { @@ -91,6 +100,9 @@ interface Validated { limit?: number; memoryIds?: string[]; reason?: string; + startTimeIso?: string; + endTimeIso?: string; + timeRange?: TimeRange | null; } function validate(toolName: string, args: Record): Validated { @@ -118,10 +130,32 @@ function validate(toolName: string, args: Record): Validated { } v.query = query.trim(); v.limit = parseLimit(args["limit"]); + try { + v.timeRange = parseTimeRange({ + start_time: args["start_time"], + end_time: args["end_time"], + }); + } catch (err) { + if (err instanceof TimeRangeError) throw new Error(err.message); + throw err; + } + if (typeof args["start_time"] === "string") v.startTimeIso = args["start_time"].trim(); + if (typeof args["end_time"] === "string") v.endTimeIso = args["end_time"].trim(); return v; } case "memory_sessions": { - v.limit = parseLimit(args["limit"], 20); + v.limit = parseLimit(args["limit"], DEFAULT_SESSION_LIMIT, MAX_SESSION_LIMIT); + try { + v.timeRange = parseTimeRange({ + start_time: args["start_time"], + end_time: args["end_time"], + }); + } catch (err) { + if (err instanceof TimeRangeError) throw new Error(err.message); + throw err; + } + if (typeof args["start_time"] === "string") v.startTimeIso = args["start_time"].trim(); + if (typeof args["end_time"] === "string") v.endTimeIso = args["end_time"].trim(); return v; } case "memory_governance_delete": { @@ -163,13 +197,23 @@ async function handleProxy( case "memory_smart_search": { const result = await handle.call("/agentmemory/smart-search", { method: "POST", - body: JSON.stringify({ query: v.query, limit: v.limit }), + body: JSON.stringify({ + query: v.query, + limit: v.limit, + start_time: v.startTimeIso, + end_time: v.endTimeIso, + }), }); return textResponse(result, true); } case "memory_sessions": { + const params = new URLSearchParams(); + if (v.limit !== undefined) params.set("limit", String(v.limit)); + if (v.startTimeIso) params.set("start_time", v.startTimeIso); + if (v.endTimeIso) params.set("end_time", v.endTimeIso); + const qs = params.toString(); const result = await handle.call( - `/agentmemory/sessions?limit=${v.limit}`, + `/agentmemory/sessions${qs ? `?${qs}` : ""}`, { method: "GET" }, ); return textResponse(result, true); @@ -229,29 +273,34 @@ async function handleLocal( const limit = v.limit ?? DEFAULT_LIMIT; const all = await kvInstance.list>("mem:memories"); - const results = all - .filter((m) => { - const text = [ - typeof m["title"] === "string" ? m["title"] : "", - typeof m["content"] === "string" ? m["content"] : "", - Array.isArray(m["files"]) ? m["files"].join(" ") : "", - Array.isArray(m["concepts"]) ? m["concepts"].join(" ") : "", - Array.isArray(m["sessionIds"]) ? m["sessionIds"].join(" ") : "", - typeof m["id"] === "string" ? m["id"] : "", - ] - .join(" ") - .toLowerCase(); - return query.split(/\s+/).every((word) => text.includes(word)); - }) - .slice(0, limit); - return textResponse({ mode: "compact", results }, true); + const matched = all.filter((m) => { + const text = [ + typeof m["title"] === "string" ? m["title"] : "", + typeof m["content"] === "string" ? m["content"] : "", + Array.isArray(m["files"]) ? m["files"].join(" ") : "", + Array.isArray(m["concepts"]) ? m["concepts"].join(" ") : "", + Array.isArray(m["sessionIds"]) ? m["sessionIds"].join(" ") : "", + typeof m["id"] === "string" ? m["id"] : "", + ] + .join(" ") + .toLowerCase(); + return query.split(/\s+/).every((word) => text.includes(word)); + }); + const memoriesInTimeRange = v.timeRange + ? matched.filter((m) => + inTimeRange(typeof m["createdAt"] === "string" ? (m["createdAt"] as string) : undefined, v.timeRange ?? null), + ) + : matched; + return textResponse({ mode: "compact", results: memoriesInTimeRange.slice(0, limit) }, true); } case "memory_sessions": { const sessions = await kvInstance.list>("mem:sessions"); - const limit = v.limit ?? 20; - return textResponse({ sessions: sessions.slice(0, limit) }, true); + const limit = v.limit ?? DEFAULT_SESSION_LIMIT; + const sessionsTyped = sessions as Array<{ startedAt: string; endedAt?: string }>; + const filtered = filterSessionsByTime(sessionsTyped, v.timeRange ?? null); + return textResponse({ sessions: filtered.slice(0, limit) }, true); } case "memory_governance_delete": { diff --git a/src/mcp/tools-registry.ts b/src/mcp/tools-registry.ts index 82d7e7ce..0cdadb88 100644 --- a/src/mcp/tools-registry.ts +++ b/src/mcp/tools-registry.ts @@ -32,6 +32,16 @@ export const CORE_TOOLS: McpToolDef[] = [ type: "number", description: "Optional token budget to trim returned results", }, + start_time: { + type: "string", + description: + "Optional ISO 8601 lower bound (inclusive). Only return observations on or after this time. Example: 2026-05-01T00:00:00Z", + }, + end_time: { + type: "string", + description: + "Optional ISO 8601 upper bound (inclusive). Only return observations on or before this time. Example: 2026-05-07T23:59:59Z", + }, }, required: ["query"], }, @@ -107,12 +117,30 @@ export const CORE_TOOLS: McpToolDef[] = [ { name: "memory_sessions", description: - "List recent sessions with their status and observation counts.", - inputSchema: { type: "object", properties: {} }, + "List recent sessions with their status and observation counts. Optionally filter by an ISO 8601 time window (sessions whose lifetime overlaps the window are returned, most-recent first).", + inputSchema: { + type: "object", + properties: { + start_time: { + type: "string", + description: + "Optional ISO 8601 lower bound (inclusive). Sessions ending before this are excluded. Example: 2026-05-01T00:00:00Z", + }, + end_time: { + type: "string", + description: + "Optional ISO 8601 upper bound (inclusive). Sessions starting after this are excluded. Example: 2026-05-07T23:59:59Z", + }, + limit: { + type: "number", + description: "Max sessions to return (default 50, max 1000)", + }, + }, + }, }, { name: "memory_smart_search", - description: "Hybrid semantic+keyword search with progressive disclosure.", + description: "Hybrid semantic+keyword search with progressive disclosure. Optionally filter by an ISO 8601 time window over observation timestamps.", inputSchema: { type: "object", properties: { @@ -122,6 +150,16 @@ export const CORE_TOOLS: McpToolDef[] = [ description: "Comma-separated observation IDs to expand", }, limit: { type: "number", description: "Max results (default 10)" }, + start_time: { + type: "string", + description: + "Optional ISO 8601 lower bound (inclusive). Only return observations on or after this time.", + }, + end_time: { + type: "string", + description: + "Optional ISO 8601 upper bound (inclusive). Only return observations on or before this time.", + }, }, required: ["query"], }, diff --git a/src/state/hybrid-search.ts b/src/state/hybrid-search.ts index d234a3ef..1c2822b2 100644 --- a/src/state/hybrid-search.ts +++ b/src/state/hybrid-search.ts @@ -16,6 +16,7 @@ import { } from "../functions/graph-retrieval.js"; import { extractEntitiesFromQuery } from "../functions/query-expansion.js"; import { rerank } from "./reranker.js"; +import { inTimeRange, type TimeRange } from "./time-filter.js"; const RRF_K = 60; @@ -35,14 +36,19 @@ export class HybridSearch { this.graphRetrieval = new GraphRetrieval(kv); } - async search(query: string, limit = 20): Promise { - return this.tripleStreamSearch(query, limit); + async search( + query: string, + limit = 20, + options: { timeRange?: TimeRange | null } = {}, + ): Promise { + return this.tripleStreamSearch(query, limit, undefined, options.timeRange ?? null); } async searchWithExpansion( query: string, limit: number, expansion: QueryExpansion, + options: { timeRange?: TimeRange | null } = {}, ): Promise { const allQueries = [ query, @@ -56,7 +62,9 @@ export class HybridSearch { ]; const resultSets = await Promise.all( - allQueries.map((q) => this.tripleStreamSearch(q, limit, allEntities)), + allQueries.map((q) => + this.tripleStreamSearch(q, limit, allEntities, options.timeRange ?? null), + ), ); const merged = new Map(); @@ -78,8 +86,15 @@ export class HybridSearch { query: string, limit: number, entityHints?: string[], + timeRange: TimeRange | null = null, ): Promise { - const bm25Results = this.bm25.search(query, limit * 2); + // When a time range is set, over-fetch from each retrieval stream and + // expand the diversification window so the post-enrichment timestamp + // filter still has enough candidates to fill the requested limit + // without collapsing recall on a narrow window. Mirrors the + // project/cwd over-fetch in mem::search (issue #392). + const candidateMultiplier = timeRange ? 5 : 2; + const bm25Results = this.bm25.search(query, limit * candidateMultiplier); let vectorResults: Array<{ obsId: string; @@ -91,7 +106,7 @@ export class HybridSearch { if (this.vector && this.embeddingProvider && this.vector.size > 0) { try { queryEmbedding = await this.embeddingProvider.embed(query); - vectorResults = this.vector.search(queryEmbedding, limit * 2); + vectorResults = this.vector.search(queryEmbedding, limit * candidateMultiplier); } catch { // fall through to BM25-only } @@ -107,7 +122,7 @@ export class HybridSearch { graphResults = await this.graphRetrieval.searchByEntities( entities, 2, - limit, + timeRange ? limit * candidateMultiplier : limit, ); } catch { // graph search is best-effort @@ -220,10 +235,22 @@ export class HybridSearch { combined.sort((a, b) => b.combinedScore - a.combinedScore); - const retrievalDepth = Math.max(limit, 20); + // Bump retrieval depth + diversify cap when a time range is set so + // the post-enrichment filter has enough headroom to surface `limit` + // matches even when the window is narrow. + const retrievalDepth = timeRange + ? Math.max(limit * candidateMultiplier, 100) + : Math.max(limit, 20); const rerankWindow = 20; - const diversified = this.diversifyBySession(combined, retrievalDepth); - const enriched = await this.enrichResults(diversified, retrievalDepth); + const diversified = this.diversifyBySession( + combined, + retrievalDepth, + timeRange ? 5 : 3, + ); + const enrichedRaw = await this.enrichResults(diversified, retrievalDepth); + const enriched = timeRange + ? enrichedRaw.filter((r) => inTimeRange(r.observation.timestamp, timeRange)) + : enrichedRaw; if (this.rerankEnabled && enriched.length > 1) { try { diff --git a/src/state/time-filter.ts b/src/state/time-filter.ts new file mode 100644 index 00000000..5891e52c --- /dev/null +++ b/src/state/time-filter.ts @@ -0,0 +1,139 @@ +// Shared time-range filter for memory_recall, memory_smart_search, and +// memory_sessions (issue #392). Used by: +// - mem::search (memory_recall) +// - mem::smart-search (memory_smart_search) +// - api::sessions / MCP memory_sessions handler +// +// Contract: +// - start_time / end_time are optional ISO 8601 strings. +// - Both bounds are inclusive: start_time <= ts <= end_time. +// - If both are omitted the range is "unbounded" and matches everything. +// - Date-only strings ("2026-05-01") parse to UTC midnight, matching the +// standard ISO 8601 semantics that Date.parse implements. +// - Errors produced here are TimeRangeError and carry an HTTP-friendly +// `code` so api triggers and MCP tool handlers can return uniform 400s. + +export class TimeRangeError extends Error { + readonly code: string; + constructor(message: string, code = "invalid_time_range") { + super(message); + this.name = "TimeRangeError"; + this.code = code; + } +} + +export interface TimeRange { + /** Lower bound (inclusive), epoch milliseconds. Undefined means unbounded. */ + start?: number; + /** Upper bound (inclusive), epoch milliseconds. Undefined means unbounded. */ + end?: number; +} + +export interface TimeRangeInput { + start_time?: unknown; + end_time?: unknown; +} + +/** + * Validate and normalize a {start_time, end_time} pair. + * + * Returns `null` when both inputs are missing or empty (callers can skip the + * filter entirely). Throws TimeRangeError when an input is present but + * unparseable, or when start_time > end_time. + */ +export function parseTimeRange(input: TimeRangeInput | undefined | null): TimeRange | null { + if (!input) return null; + + const start = parseBound(input.start_time, "start_time"); + const end = parseBound(input.end_time, "end_time"); + + if (start === undefined && end === undefined) return null; + if (start !== undefined && end !== undefined && start > end) { + throw new TimeRangeError( + "start_time must be <= end_time", + "start_after_end", + ); + } + return { start, end }; +} + +function parseBound(value: unknown, field: "start_time" | "end_time"): number | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value !== "string") { + throw new TimeRangeError(`${field} must be an ISO 8601 string`, "not_a_string"); + } + const trimmed = value.trim(); + if (trimmed.length === 0) return undefined; + const ms = Date.parse(trimmed); + if (Number.isNaN(ms)) { + throw new TimeRangeError( + `${field} is not a valid ISO 8601 datetime: ${JSON.stringify(trimmed)}`, + "unparseable", + ); + } + return ms; +} + +/** + * Test whether an ISO timestamp falls inside a normalized range. + * + * Defensive against malformed timestamps in the store: a row with a missing + * or unparseable timestamp is treated as out-of-range when *any* bound is + * set (it would otherwise silently slip through). When `range` is null the + * predicate is the identity true — callers should short-circuit instead of + * calling this in the hot path, but it stays correct either way. + */ +export function inTimeRange(timestamp: string | undefined, range: TimeRange | null): boolean { + if (!range) return true; + if (typeof timestamp !== "string" || timestamp.length === 0) return false; + const ms = Date.parse(timestamp); + if (Number.isNaN(ms)) return false; + if (range.start !== undefined && ms < range.start) return false; + if (range.end !== undefined && ms > range.end) return false; + return true; +} + +/** + * Filter a session list by overlap with `range`. A session is in-range when + * its lifetime [startedAt, endedAt ?? now) intersects the bounded window — + * this matches user intent ("show me sessions from May 1-7" should include a + * session that started April 30 and ended May 2). + * + * Sessions are returned in descending startedAt order (most recent first), + * which is what every existing caller expects. + */ +export function filterSessionsByTime( + sessions: T[], + range: TimeRange | null, +): T[] { + if (!range) return [...sessions].sort(byStartedAtDesc); + + const nowMs = Date.now(); + const filtered: T[] = []; + for (const s of sessions) { + if (typeof s.startedAt !== "string" || s.startedAt.length === 0) continue; + const startMs = Date.parse(s.startedAt); + if (Number.isNaN(startMs)) continue; + let endMs: number; + if (typeof s.endedAt === "string" && s.endedAt.length > 0) { + const parsed = Date.parse(s.endedAt); + endMs = Number.isNaN(parsed) ? nowMs : parsed; + } else { + // Active sessions have no endedAt; treat as "still running". + endMs = nowMs; + } + // Half-open lifetime overlap with closed [start, end] window. + if (range.start !== undefined && endMs <= range.start) continue; + if (range.end !== undefined && startMs > range.end) continue; + filtered.push(s); + } + return filtered.sort(byStartedAtDesc); +} + +function byStartedAtDesc(a: T, b: T): number { + // Pure string compare on ISO 8601 is byte-equivalent to chronological + // compare for any well-formed input, so we avoid the parse cost on the + // common path. Fall back to 0 when both are missing/equal. + if (a.startedAt === b.startedAt) return 0; + return a.startedAt < b.startedAt ? 1 : -1; +} diff --git a/src/triggers/api.ts b/src/triggers/api.ts index 6a65f233..60880ab9 100644 --- a/src/triggers/api.ts +++ b/src/triggers/api.ts @@ -17,6 +17,11 @@ import { detectEmbeddingProvider, detectLlmProviderKind, } from "../config.js"; +import { + filterSessionsByTime, + parseTimeRange, + TimeRangeError, +} from "../state/time-filter.js"; type Response = { status_code: number; @@ -344,6 +349,8 @@ export function registerApiTriggers( cwd?: string; format?: string; token_budget?: number; + start_time?: string; + end_time?: string; }>, ): Promise => { const body = (req.body ?? {}) as Record; @@ -381,6 +388,19 @@ export function registerApiTriggers( body: { error: "token_budget must be a positive integer" }, }; } + // Validate start_time/end_time up front so callers get a clean 400 + // before the underlying mem::search runs (issue #392). + try { + parseTimeRange({ + start_time: body.start_time, + end_time: body.end_time, + }); + } catch (err) { + if (err instanceof TimeRangeError) { + return { status_code: 400, body: { error: err.message, code: err.code } }; + } + throw err; + } const payload = { query: body.query.trim(), limit: body.limit as number | undefined, @@ -391,6 +411,8 @@ export function registerApiTriggers( ? body.format.trim().toLowerCase() : undefined, token_budget: body.token_budget as number | undefined, + start_time: typeof body.start_time === "string" ? body.start_time : undefined, + end_time: typeof body.end_time === "string" ? body.end_time : undefined, }; const result = await sdk.trigger({ function_id: "mem::search", payload: payload }); return { status_code: 200, body: result }; @@ -613,7 +635,42 @@ export function registerApiTriggers( async (req: ApiRequest): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; - const sessions = await kv.list(KV.sessions); + + // Optional ISO 8601 time-range + limit filtering (issue #392). + // Both bounds are inclusive; sessions whose lifetime overlaps the + // window are returned in descending startedAt order. + const startRaw = req.query_params?.["start_time"]; + const endRaw = req.query_params?.["end_time"]; + const limitRaw = req.query_params?.["limit"]; + + let timeRange; + try { + timeRange = parseTimeRange({ + start_time: startRaw, + end_time: endRaw, + }); + } catch (err) { + if (err instanceof TimeRangeError) { + return { status_code: 400, body: { error: err.message, code: err.code } }; + } + throw err; + } + + let limit = 50; + if (limitRaw !== undefined && limitRaw !== "") { + const parsed = parseOptionalInt(limitRaw); + if (parsed === undefined || !Number.isInteger(parsed) || parsed < 1) { + return { + status_code: 400, + body: { error: "limit must be a positive integer" }, + }; + } + limit = Math.min(parsed, 1000); + } + + let sessions = await kv.list(KV.sessions); + sessions = filterSessionsByTime(sessions, timeRange); + sessions = sessions.slice(0, limit); return { status_code: 200, body: { sessions } }; }, ); @@ -836,20 +893,80 @@ export function registerApiTriggers( sdk.registerFunction("api::smart-search", async ( - req: ApiRequest<{ query?: string; expandIds?: string[]; limit?: number }>, + req: ApiRequest<{ + query?: string; + expandIds?: string[]; + limit?: number; + start_time?: string; + end_time?: string; + }>, ): Promise => { const authErr = checkAuth(req, secret); if (authErr) return authErr; + const body = (req.body ?? {}) as Record; + const query = asNonEmptyString(body.query); if ( - !req.body?.query && - (!req.body?.expandIds || req.body.expandIds.length === 0) + body.expandIds !== undefined && + (!Array.isArray(body.expandIds) || + !body.expandIds.every((id) => typeof id === "string")) ) { + return { + status_code: 400, + body: { error: "expandIds must be an array of strings" }, + }; + } + const hasExpandIds = Array.isArray(body.expandIds) && body.expandIds.length > 0; + if (!query && !hasExpandIds) { return { status_code: 400, body: { error: "query or expandIds is required" }, }; } - const result = await sdk.trigger({ function_id: "mem::smart-search", payload: req.body }); + const limit = parseOptionalPositiveInt(body.limit); + if (limit === null) { + return { + status_code: 400, + body: { error: "limit must be a positive integer" }, + }; + } + if (!query && (body.start_time !== undefined || body.end_time !== undefined)) { + return { + status_code: 400, + body: { + error: "start_time and end_time require query for smart search", + code: "invalid_time_range", + }, + }; + } + if (query && (body.start_time !== undefined || body.end_time !== undefined)) { + try { + parseTimeRange({ + start_time: body.start_time, + end_time: body.end_time, + }); + } catch (err) { + if (err instanceof TimeRangeError) { + return { + status_code: 400, + body: { error: err.message, code: err.code }, + }; + } + throw err; + } + } + const payload: { + query?: string; + expandIds?: string[]; + limit?: number; + start_time?: string; + end_time?: string; + } = {}; + if (query) payload.query = query; + if (hasExpandIds) payload.expandIds = body.expandIds as string[]; + if (limit !== undefined) payload.limit = limit; + if (typeof body.start_time === "string") payload.start_time = body.start_time; + if (typeof body.end_time === "string") payload.end_time = body.end_time; + const result = await sdk.trigger({ function_id: "mem::smart-search", payload }); return { status_code: 200, body: result }; }, ); diff --git a/test/mcp-standalone.test.ts b/test/mcp-standalone.test.ts index 59bd985e..07895f0e 100644 --- a/test/mcp-standalone.test.ts +++ b/test/mcp-standalone.test.ts @@ -309,6 +309,27 @@ describe("handleToolCall", () => { expect(parsed.sessions).toHaveLength(2); }); + it("memory_sessions defaults to 50 and caps at 1000", async () => { + const kv = new InMemoryKV(); + for (let i = 0; i < 1005; i++) { + await kv.set("mem:sessions", `ses_${i}`, { + id: `ses_${i}`, + project: "demo", + startedAt: new Date(Date.UTC(2026, 0, 1, 0, i)).toISOString(), + }); + } + + const defaultResult = await handleToolCall("memory_sessions", {}, kv); + expect(JSON.parse(defaultResult.content[0].text).sessions).toHaveLength(50); + + const hugeResult = await handleToolCall( + "memory_sessions", + { limit: 99999 }, + kv, + ); + expect(JSON.parse(hugeResult.content[0].text).sessions).toHaveLength(1000); + }); + it("parseLimit clamps bad/malicious limit values to a safe range", async () => { const kv = new InMemoryKV(); for (let i = 0; i < 150; i++) { diff --git a/test/time-filter-mcp-shim.test.ts b/test/time-filter-mcp-shim.test.ts new file mode 100644 index 00000000..b8678170 --- /dev/null +++ b/test/time-filter-mcp-shim.test.ts @@ -0,0 +1,221 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { handleToolCall } from "../src/mcp/standalone.js"; +import { resetHandleForTests } from "../src/mcp/rest-proxy.js"; +import { InMemoryKV } from "../src/mcp/in-memory-kv.js"; + +type FetchMock = ReturnType; + +function installFetch(handler: (url: string, init?: RequestInit) => Response): FetchMock { + const fn = vi.fn(async (url: string | URL, init?: RequestInit) => + handler(url.toString(), init), + ); + (globalThis as { fetch: typeof fetch }).fetch = fn as unknown as typeof fetch; + return fn; +} + +const BASE = "http://localhost:3111"; + +describe("@agentmemory/mcp standalone — time range forwarding (issue #392)", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + resetHandleForTests(); + process.env["AGENTMEMORY_URL"] = BASE; + delete process.env["AGENTMEMORY_SECRET"]; + }); + + afterEach(() => { + resetHandleForTests(); + globalThis.fetch = originalFetch; + delete process.env["AGENTMEMORY_URL"]; + }); + + it("proxies start_time/end_time on memory_smart_search to the server body", async () => { + let observedBody: Record | null = null; + installFetch((url, init) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.endsWith("/agentmemory/smart-search")) { + observedBody = init?.body ? JSON.parse(init.body as string) : null; + return new Response( + JSON.stringify({ mode: "compact", results: [] }), + { status: 200 }, + ); + } + return new Response("", { status: 404 }); + }); + + await handleToolCall("memory_smart_search", { + query: "auth", + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + expect(observedBody).not.toBeNull(); + expect(observedBody!.start_time).toBe("2026-05-01T00:00:00Z"); + expect(observedBody!.end_time).toBe("2026-05-07T23:59:59Z"); + }); + + it("proxies start_time/end_time on memory_recall to /agentmemory/smart-search body", async () => { + let observedBody: Record | null = null; + installFetch((url, init) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.endsWith("/agentmemory/smart-search")) { + observedBody = init?.body ? JSON.parse(init.body as string) : null; + return new Response( + JSON.stringify({ mode: "compact", results: [] }), + { status: 200 }, + ); + } + return new Response("", { status: 404 }); + }); + + await handleToolCall("memory_recall", { + query: "auth", + start_time: "2026-05-01T00:00:00Z", + }); + expect(observedBody).not.toBeNull(); + expect(observedBody!.start_time).toBe("2026-05-01T00:00:00Z"); + // end_time is omitted from the request body when not provided. + expect(observedBody!.end_time).toBeUndefined(); + }); + + it("proxies start_time/end_time/limit on memory_sessions as query params", async () => { + let observedUrl: string | null = null; + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.includes("/agentmemory/sessions")) { + observedUrl = url; + return new Response(JSON.stringify({ sessions: [] }), { status: 200 }); + } + return new Response("", { status: 404 }); + }); + + await handleToolCall("memory_sessions", { + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + limit: 25, + }); + expect(observedUrl).not.toBeNull(); + const u = new URL(observedUrl!); + expect(u.searchParams.get("start_time")).toBe("2026-05-01T00:00:00Z"); + expect(u.searchParams.get("end_time")).toBe("2026-05-07T23:59:59Z"); + expect(u.searchParams.get("limit")).toBe("25"); + }); + + it("rejects malformed start_time before the proxy call goes out", async () => { + let smartSearchHits = 0; + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.endsWith("/agentmemory/smart-search")) smartSearchHits++; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + await expect( + handleToolCall("memory_smart_search", { + query: "auth", + start_time: "yesterday", + }), + ).rejects.toThrow(/ISO 8601/); + expect(smartSearchHits).toBe(0); + }); + + it("rejects start_time > end_time before the proxy call goes out", async () => { + let smartSearchHits = 0; + installFetch((url) => { + if (url.endsWith("/agentmemory/livez")) return new Response("ok", { status: 200 }); + if (url.endsWith("/agentmemory/smart-search")) smartSearchHits++; + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }); + + await expect( + handleToolCall("memory_smart_search", { + query: "auth", + start_time: "2026-06-01T00:00:00Z", + end_time: "2026-05-01T00:00:00Z", + }), + ).rejects.toThrow(/start_time must be <= end_time/); + expect(smartSearchHits).toBe(0); + }); + + it("local fallback applies time filter on memory_sessions", async () => { + installFetch(() => { + throw new Error("ECONNREFUSED"); + }); + const localKv = new InMemoryKV(undefined); + + await localKv.set("mem:sessions", "s_in", { + id: "s_in", + startedAt: "2026-05-03T00:00:00Z", + endedAt: "2026-05-03T01:00:00Z", + }); + await localKv.set("mem:sessions", "s_before", { + id: "s_before", + startedAt: "2026-04-01T00:00:00Z", + endedAt: "2026-04-01T01:00:00Z", + }); + await localKv.set("mem:sessions", "s_after", { + id: "s_after", + startedAt: "2026-06-01T00:00:00Z", + endedAt: "2026-06-01T01:00:00Z", + }); + + const res = await handleToolCall( + "memory_sessions", + { + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-31T23:59:59Z", + }, + localKv, + ); + const body = JSON.parse(res.content[0].text); + expect(body.sessions).toHaveLength(1); + expect(body.sessions[0].id).toBe("s_in"); + }); + + it("local fallback applies time filter on memory_recall against memory.createdAt", async () => { + installFetch(() => { + throw new Error("ECONNREFUSED"); + }); + const localKv = new InMemoryKV(undefined); + + await handleToolCall( + "memory_save", + { content: "auth jwt note" }, + localKv, + ); + // Backdate the freshly-saved memory so it sits outside the window. + const memList = await localKv.list>("mem:memories"); + expect(memList).toHaveLength(1); + const stored = memList[0] as { id: string; createdAt: string }; + await localKv.set("mem:memories", stored.id, { + ...stored, + createdAt: "2026-04-15T00:00:00Z", + }); + // And add a fresh one inside the window. + await handleToolCall( + "memory_save", + { content: "auth refresh in window" }, + localKv, + ); + const refreshed = await localKv.list>("mem:memories"); + const inWindow = refreshed.find( + (m) => m["content"] === "auth refresh in window", + ) as { id: string; createdAt: string }; + await localKv.set("mem:memories", inWindow.id, { + ...inWindow, + createdAt: "2026-05-03T00:00:00Z", + }); + + const res = await handleToolCall( + "memory_recall", + { + query: "auth", + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-31T23:59:59Z", + }, + localKv, + ); + const body = JSON.parse(res.content[0].text); + expect(body.results).toHaveLength(1); + expect(body.results[0].content).toBe("auth refresh in window"); + }); +}); diff --git a/test/time-filter.test.ts b/test/time-filter.test.ts new file mode 100644 index 00000000..33a66a5e --- /dev/null +++ b/test/time-filter.test.ts @@ -0,0 +1,686 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../src/logger.js", () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +import { + filterSessionsByTime, + inTimeRange, + parseTimeRange, + TimeRangeError, +} from "../src/state/time-filter.js"; +import { + registerSearchFunction, + getSearchIndex, +} from "../src/functions/search.js"; +import { registerSmartSearchFunction } from "../src/functions/smart-search.js"; +import { registerApiTriggers } from "../src/triggers/api.js"; +import { registerMcpEndpoints } from "../src/mcp/server.js"; +import { KV } from "../src/state/schema.js"; +import type { + CompressedObservation, + HybridSearchResult, + Session, +} from "../src/types.js"; + +// ---------- mocks ---------- + +function mockKV() { + const store = new Map>(); + return { + get: async (scope: string, key: string): Promise => { + return (store.get(scope)?.get(key) as T) ?? null; + }, + set: async (scope: string, key: string, data: T): Promise => { + if (!store.has(scope)) store.set(scope, new Map()); + store.get(scope)!.set(key, data); + return data; + }, + delete: async (scope: string, key: string): Promise => { + store.get(scope)?.delete(key); + }, + list: async (scope: string): Promise => { + const entries = store.get(scope); + return entries ? (Array.from(entries.values()) as T[]) : []; + }, + }; +} + +function mockSdk() { + const functions = new Map(); + const triggerOverrides = new Map(); + return { + registerFunction: (idOrOpts: string | { id: string }, handler: Function) => { + const id = typeof idOrOpts === "string" ? idOrOpts : idOrOpts.id; + functions.set(id, handler); + }, + registerTrigger: () => {}, + trigger: async ( + idOrInput: string | { function_id: string; payload: unknown }, + data?: unknown, + ) => { + const id = typeof idOrInput === "string" ? idOrInput : idOrInput.function_id; + const payload = typeof idOrInput === "string" ? data : idOrInput.payload; + if (triggerOverrides.has(id)) { + return triggerOverrides.get(id)!(payload); + } + const fn = functions.get(id); + if (!fn) throw new Error(`No function: ${id}`); + return fn(payload); + }, + overrideTrigger: (id: string, handler: Function) => { + triggerOverrides.set(id, handler); + }, + getFunction: (id: string) => functions.get(id), + }; +} + +function makeReq(body?: unknown, query_params: Record = {}) { + return { + body, + headers: {}, + query_params, + }; +} + +function testSession(id: string, minute: number): Session { + return { + id, + project: "demo", + cwd: "/tmp/demo", + startedAt: new Date(Date.UTC(2026, 0, 1, 0, minute)).toISOString(), + status: "completed", + observationCount: 0, + }; +} + +// ---------- parseTimeRange ---------- + +describe("parseTimeRange", () => { + it("returns null when both inputs are absent", () => { + expect(parseTimeRange({})).toBeNull(); + expect(parseTimeRange(null)).toBeNull(); + expect(parseTimeRange(undefined)).toBeNull(); + }); + + it("returns null when both inputs are empty strings", () => { + expect(parseTimeRange({ start_time: "", end_time: "" })).toBeNull(); + }); + + it("parses a valid ISO 8601 datetime with timezone", () => { + const r = parseTimeRange({ + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + expect(r).not.toBeNull(); + expect(r!.start).toBe(Date.parse("2026-05-01T00:00:00Z")); + expect(r!.end).toBe(Date.parse("2026-05-07T23:59:59Z")); + }); + + it("parses a date-only ISO 8601 string", () => { + const r = parseTimeRange({ start_time: "2026-05-01" }); + expect(r).not.toBeNull(); + expect(r!.start).toBe(Date.parse("2026-05-01")); + expect(r!.end).toBeUndefined(); + }); + + it("accepts only one bound", () => { + const onlyStart = parseTimeRange({ start_time: "2026-05-01T00:00:00Z" }); + expect(onlyStart!.start).toBeDefined(); + expect(onlyStart!.end).toBeUndefined(); + + const onlyEnd = parseTimeRange({ end_time: "2026-05-07T23:59:59Z" }); + expect(onlyEnd!.start).toBeUndefined(); + expect(onlyEnd!.end).toBeDefined(); + }); + + it("trims whitespace around bounds", () => { + const r = parseTimeRange({ start_time: " 2026-05-01T00:00:00Z " }); + expect(r!.start).toBe(Date.parse("2026-05-01T00:00:00Z")); + }); + + it("throws TimeRangeError on unparseable strings", () => { + expect(() => parseTimeRange({ start_time: "not-a-date" })).toThrow( + TimeRangeError, + ); + expect(() => parseTimeRange({ end_time: "yesterday" })).toThrow( + TimeRangeError, + ); + }); + + it("throws TimeRangeError when start > end", () => { + try { + parseTimeRange({ + start_time: "2026-05-07T00:00:00Z", + end_time: "2026-05-01T00:00:00Z", + }); + expect.fail("should have thrown"); + } catch (err) { + expect(err).toBeInstanceOf(TimeRangeError); + expect((err as TimeRangeError).code).toBe("start_after_end"); + } + }); + + it("throws TimeRangeError when value is not a string", () => { + expect(() => + parseTimeRange({ start_time: 123 as unknown as string }), + ).toThrow(TimeRangeError); + }); + + it("treats start === end as valid (zero-length window)", () => { + const t = "2026-05-01T00:00:00Z"; + const r = parseTimeRange({ start_time: t, end_time: t }); + expect(r!.start).toBe(r!.end); + }); +}); + +// ---------- inTimeRange ---------- + +describe("inTimeRange", () => { + const range = parseTimeRange({ + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + + it("returns true for timestamps inside the window", () => { + expect(inTimeRange("2026-05-03T12:00:00Z", range)).toBe(true); + }); + + it("treats both bounds as inclusive", () => { + expect(inTimeRange("2026-05-01T00:00:00Z", range)).toBe(true); + expect(inTimeRange("2026-05-07T23:59:59Z", range)).toBe(true); + }); + + it("returns false for timestamps outside the window", () => { + expect(inTimeRange("2026-04-30T23:59:59Z", range)).toBe(false); + expect(inTimeRange("2026-05-08T00:00:00Z", range)).toBe(false); + }); + + it("returns true when range is null", () => { + expect(inTimeRange("2026-05-03T12:00:00Z", null)).toBe(true); + }); + + it("returns false for malformed timestamps when a range is set", () => { + expect(inTimeRange("", range)).toBe(false); + expect(inTimeRange(undefined, range)).toBe(false); + expect(inTimeRange("not-a-date", range)).toBe(false); + }); + + it("respects start-only ranges", () => { + const startOnly = parseTimeRange({ start_time: "2026-05-01T00:00:00Z" }); + expect(inTimeRange("2026-04-30T00:00:00Z", startOnly)).toBe(false); + expect(inTimeRange("2027-01-01T00:00:00Z", startOnly)).toBe(true); + }); + + it("respects end-only ranges", () => { + const endOnly = parseTimeRange({ end_time: "2026-05-07T23:59:59Z" }); + expect(inTimeRange("2026-04-30T00:00:00Z", endOnly)).toBe(true); + expect(inTimeRange("2026-05-08T00:00:00Z", endOnly)).toBe(false); + }); +}); + +// ---------- filterSessionsByTime ---------- + +describe("filterSessionsByTime", () => { + const sessions = [ + { id: "s1", startedAt: "2026-04-30T20:00:00Z", endedAt: "2026-05-02T08:00:00Z" }, // overlaps left edge + { id: "s2", startedAt: "2026-05-03T12:00:00Z", endedAt: "2026-05-03T18:00:00Z" }, // fully inside + { id: "s3", startedAt: "2026-05-07T22:00:00Z" }, // active, started near right edge + { id: "s4", startedAt: "2026-05-08T01:00:00Z", endedAt: "2026-05-08T02:00:00Z" }, // fully after + { id: "s5", startedAt: "2026-04-01T00:00:00Z", endedAt: "2026-04-02T00:00:00Z" }, // fully before + { id: "s6", startedAt: "" }, // malformed + ]; + + it("returns all sessions sorted desc when range is null", () => { + const out = filterSessionsByTime(sessions, null); + expect(out.map((s) => s.id)).toEqual(["s4", "s3", "s2", "s1", "s5", "s6"]); + }); + + it("includes sessions whose lifetime overlaps the window", () => { + const range = parseTimeRange({ + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + const out = filterSessionsByTime(sessions, range); + const ids = out.map((s) => s.id).sort(); + // s1 (overlaps left), s2 (inside), s3 (active at right edge) all qualify; + // s4/s5 are fully outside, s6 is malformed. + expect(ids).toEqual(["s1", "s2", "s3"]); + }); + + it("orders results by startedAt descending", () => { + const range = parseTimeRange({ + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + const out = filterSessionsByTime(sessions, range); + expect(out.map((s) => s.id)).toEqual(["s3", "s2", "s1"]); + }); + + it("excludes a session ending exactly at the window start", () => { + const range = parseTimeRange({ + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + const out = filterSessionsByTime( + [ + { + id: "touches_left_edge", + startedAt: "2026-04-30T00:00:00Z", + endedAt: "2026-05-01T00:00:00Z", + }, + { + id: "overlaps_left_edge", + startedAt: "2026-04-30T00:00:00Z", + endedAt: "2026-05-01T00:00:01Z", + }, + ], + range, + ); + + expect(out.map((s) => s.id)).toEqual(["overlaps_left_edge"]); + }); + + it("treats an active session (no endedAt) as still running for the window check", () => { + const range = parseTimeRange({ + start_time: "2026-05-07T00:00:00Z", + end_time: "2026-05-07T23:00:00Z", + }); + const out = filterSessionsByTime(sessions, range); + expect(out.map((s) => s.id)).toContain("s3"); + }); + + it("captures now once for active or malformed-ended sessions", () => { + const nowSpy = vi.spyOn(Date, "now").mockReturnValue( + Date.parse("2026-05-10T00:00:00Z"), + ); + try { + const range = parseTimeRange({ + start_time: "2026-05-09T00:00:00Z", + end_time: "2026-05-11T00:00:00Z", + }); + const out = filterSessionsByTime( + [ + { id: "active", startedAt: "2026-05-01T00:00:00Z" }, + { + id: "bad_end", + startedAt: "2026-05-01T00:00:00Z", + endedAt: "bad-date", + }, + ], + range, + ); + + expect(out.map((s) => s.id).sort()).toEqual(["active", "bad_end"]); + expect(nowSpy).toHaveBeenCalledTimes(1); + } finally { + nowSpy.mockRestore(); + } + }); +}); + +// ---------- REST / MCP sessions surface ---------- + +describe("api::sessions time-range surface", () => { + it("defaults memory_sessions to 50 results", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerApiTriggers(sdk as never, kv as never); + + for (let i = 0; i < 60; i++) { + const session = testSession(`ses_${i}`, i); + await kv.set(KV.sessions, session.id, session); + } + + const fn = sdk.getFunction("api::sessions")!; + const result = (await fn(makeReq())) as { + status_code: number; + body: { sessions: Session[] }; + }; + + expect(result.status_code).toBe(200); + expect(result.body.sessions).toHaveLength(50); + expect(result.body.sessions[0].id).toBe("ses_59"); + }); + + it("rejects non-string time bounds instead of ignoring them", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerApiTriggers(sdk as never, kv as never); + + const fn = sdk.getFunction("api::sessions")!; + const result = (await fn(makeReq(undefined, { start_time: 123 }))) as { + status_code: number; + body: { code?: string; error?: string }; + }; + + expect(result.status_code).toBe(400); + expect(result.body.code).toBe("not_a_string"); + expect(result.body.error).toMatch(/start_time must be an ISO 8601 string/); + }); +}); + +describe("api::smart-search time-range surface", () => { + it("passes only whitelisted fields to mem::smart-search", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + let captured: Record | null = null; + sdk.overrideTrigger("mem::smart-search", async (payload: Record) => { + captured = payload; + return { mode: "compact", results: [] }; + }); + registerApiTriggers(sdk as never, kv as never); + + const fn = sdk.getFunction("api::smart-search")!; + const result = (await fn( + makeReq({ + query: " auth ", + expandIds: ["obs_1"], + limit: 7, + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + ignored: true, + }), + )) as { status_code: number }; + + expect(result.status_code).toBe(200); + expect(captured).toEqual({ + query: "auth", + expandIds: ["obs_1"], + limit: 7, + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + }); + + it("rejects time bounds on expandIds-only smart search", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + let hits = 0; + sdk.overrideTrigger("mem::smart-search", async () => { + hits++; + return { mode: "expanded", results: [] }; + }); + registerApiTriggers(sdk as never, kv as never); + + const fn = sdk.getFunction("api::smart-search")!; + const result = (await fn( + makeReq({ + expandIds: ["obs_1"], + start_time: "2026-05-01T00:00:00Z", + }), + )) as { + status_code: number; + body: { code?: string; error?: string }; + }; + + expect(result.status_code).toBe(400); + expect(result.body.code).toBe("invalid_time_range"); + expect(result.body.error).toMatch(/require query/); + expect(hits).toBe(0); + }); +}); + +describe("MCP time-range surface", () => { + it("defaults memory_sessions to 50 results", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerMcpEndpoints(sdk as never, kv as never); + + for (let i = 0; i < 60; i++) { + const session = testSession(`ses_${i}`, i); + await kv.set(KV.sessions, session.id, session); + } + + const fn = sdk.getFunction("mcp::tools::call")!; + const result = (await fn( + makeReq({ name: "memory_sessions", arguments: {} }), + )) as { + status_code: number; + body: { content: Array<{ text: string }> }; + }; + const body = JSON.parse(result.body.content[0].text); + + expect(result.status_code).toBe(200); + expect(body.sessions).toHaveLength(50); + expect(body.sessions[0].id).toBe("ses_59"); + }); + + it("rejects non-string time bounds for time-aware tools", async () => { + const sdk = mockSdk(); + const kv = mockKV(); + registerMcpEndpoints(sdk as never, kv as never); + sdk.overrideTrigger("mem::search", async () => { + throw new Error("mem::search should not run"); + }); + sdk.overrideTrigger("mem::smart-search", async () => { + throw new Error("mem::smart-search should not run"); + }); + + const fn = sdk.getFunction("mcp::tools::call")!; + const calls = [ + { name: "memory_recall", args: { query: "auth", start_time: 123 } }, + { name: "memory_smart_search", args: { query: "auth", end_time: 123 } }, + { name: "memory_sessions", args: { start_time: 123 } }, + ]; + + for (const call of calls) { + const result = (await fn( + makeReq({ name: call.name, arguments: call.args }), + )) as { + status_code: number; + body: { code?: string; error?: string }; + }; + expect(result.status_code).toBe(400); + expect(result.body.code).toBe("not_a_string"); + expect(result.body.error).toMatch(/must be an ISO 8601 string/); + } + }); +}); + +// ---------- mem::search integration ---------- + +describe("mem::search with time range", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(async () => { + sdk = mockSdk(); + kv = mockKV(); + registerSearchFunction(sdk as never, kv as never); + + const session: Session = { + id: "ses_1", + project: "demo", + cwd: "/tmp/demo", + startedAt: "2026-05-01T00:00:00Z", + status: "completed", + observationCount: 3, + }; + await kv.set(KV.sessions, session.id, session); + + const obsApr: CompressedObservation = { + id: "obs_apr", + sessionId: "ses_1", + timestamp: "2026-04-15T10:00:00Z", + type: "decision", + title: "auth jwt strategy", + facts: ["Use rotating refresh tokens"], + narrative: "Picked JWT with rotating refresh.", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 8, + }; + const obsMay3: CompressedObservation = { + id: "obs_may_3", + sessionId: "ses_1", + timestamp: "2026-05-03T12:00:00Z", + type: "decision", + title: "auth refresh rotation", + facts: ["Rotate refresh tokens every login"], + narrative: "Settled on rotate-on-use refresh tokens.", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 8, + }; + const obsJun: CompressedObservation = { + id: "obs_jun", + sessionId: "ses_1", + timestamp: "2026-06-10T09:00:00Z", + type: "decision", + title: "auth rate limit decision", + facts: ["Add per-user rate limits"], + narrative: "Chose rate limit middleware after auth.", + concepts: ["auth"], + files: ["src/auth.ts"], + importance: 7, + }; + + await kv.set(KV.observations("ses_1"), obsApr.id, obsApr); + await kv.set(KV.observations("ses_1"), obsMay3.id, obsMay3); + await kv.set(KV.observations("ses_1"), obsJun.id, obsJun); + + getSearchIndex().clear(); + }); + + it("returns all observations when no time range is set", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth", + format: "compact", + })) as { results: Array<{ obsId: string }> }; + const ids = result.results.map((r) => r.obsId).sort(); + expect(ids).toEqual(["obs_apr", "obs_jun", "obs_may_3"]); + }); + + it("filters out observations outside [start_time, end_time]", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth", + format: "compact", + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-31T23:59:59Z", + })) as { results: Array<{ obsId: string; timestamp: string }> }; + expect(result.results).toHaveLength(1); + expect(result.results[0]?.obsId).toBe("obs_may_3"); + }); + + it("respects start_time only (open upper bound)", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth", + format: "compact", + start_time: "2026-05-01T00:00:00Z", + })) as { results: Array<{ obsId: string }> }; + const ids = result.results.map((r) => r.obsId).sort(); + expect(ids).toEqual(["obs_jun", "obs_may_3"]); + }); + + it("respects end_time only (open lower bound)", async () => { + const result = (await sdk.trigger("mem::search", { + query: "auth", + format: "compact", + end_time: "2026-05-31T23:59:59Z", + })) as { results: Array<{ obsId: string }> }; + const ids = result.results.map((r) => r.obsId).sort(); + expect(ids).toEqual(["obs_apr", "obs_may_3"]); + }); + + it("rejects unparseable start_time with a 400-style error", async () => { + await expect( + sdk.trigger("mem::search", { query: "auth", start_time: "yesterday" }), + ).rejects.toThrow(/start_time is not a valid ISO 8601/); + }); + + it("rejects start_time > end_time", async () => { + await expect( + sdk.trigger("mem::search", { + query: "auth", + start_time: "2026-06-01T00:00:00Z", + end_time: "2026-05-01T00:00:00Z", + }), + ).rejects.toThrow(/start_time must be <= end_time/); + }); +}); + +// ---------- mem::smart-search integration ---------- + +describe("mem::smart-search with time range", () => { + let sdk: ReturnType; + let kv: ReturnType; + + beforeEach(() => { + sdk = mockSdk(); + kv = mockKV(); + }); + + it("forwards timeRange to the searchFn closure", async () => { + let captured: { limit: number; timeRange: unknown } | null = null; + const fakeSearch = async ( + _q: string, + limit: number, + options?: { timeRange?: unknown }, + ): Promise => { + captured = { limit, timeRange: options?.timeRange ?? null }; + return []; + }; + registerSmartSearchFunction(sdk as never, kv as never, fakeSearch); + + await sdk.trigger("mem::smart-search", { + query: "auth", + start_time: "2026-05-01T00:00:00Z", + end_time: "2026-05-07T23:59:59Z", + }); + + expect(captured).not.toBeNull(); + expect(captured!.timeRange).toMatchObject({ + start: Date.parse("2026-05-01T00:00:00Z"), + end: Date.parse("2026-05-07T23:59:59Z"), + }); + }); + + it("forwards null timeRange when neither bound is supplied", async () => { + let captured: { hasOption: boolean; timeRange: unknown } | null = null; + const fakeSearch = async ( + _q: string, + _l: number, + options?: { timeRange?: unknown }, + ): Promise => { + captured = { + hasOption: options !== undefined && "timeRange" in options, + timeRange: options?.timeRange, + }; + return []; + }; + registerSmartSearchFunction(sdk as never, kv as never, fakeSearch); + + await sdk.trigger("mem::smart-search", { query: "auth" }); + expect(captured!.hasOption).toBe(true); + expect(captured!.timeRange).toBeNull(); + }); + + it("returns a 400-style error response for malformed start_time", async () => { + const fakeSearch = async (): Promise => []; + registerSmartSearchFunction(sdk as never, kv as never, fakeSearch); + + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + start_time: "not-a-date", + })) as { mode: string; error?: string; results: unknown[] }; + + expect(result.mode).toBe("compact"); + expect(result.error).toMatch(/start_time is not a valid ISO 8601/); + expect(result.results).toEqual([]); + }); + + it("returns a 400-style error response when start_time > end_time", async () => { + const fakeSearch = async (): Promise => []; + registerSmartSearchFunction(sdk as never, kv as never, fakeSearch); + + const result = (await sdk.trigger("mem::smart-search", { + query: "auth", + start_time: "2026-06-01T00:00:00Z", + end_time: "2026-05-01T00:00:00Z", + })) as { mode: string; error?: string }; + + expect(result.error).toMatch(/start_time must be <= end_time/); + }); +});