Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>` 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.
Expand Down
26 changes: 22 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
54 changes: 42 additions & 12 deletions src/functions/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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<typeof parseTimeRange>
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)

Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -339,6 +368,7 @@ export function registerSearchFunction(sdk: ISdk, kv: StateKV): void {
results: packed.items.length,
hasProjectFilter: !!projectFilter,
hasCwdFilter: !!cwdFilter,
hasTimeRange: !!timeRange,
})
return {
format,
Expand Down
29 changes: 27 additions & 2 deletions src/functions/smart-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HybridSearchResult[]>,
searchFn: (
query: string,
limit: number,
options?: { timeRange?: TimeRange | null },
) => Promise<HybridSearchResult[]>,
): void {
sdk.registerFunction("mem::smart-search",
async (data: {
query?: string;
expandIds?: Array<string | { obsId: string; sessionId: string }>;
limit?: number;
start_time?: string;
end_time?: string;
}) => {

if (data.expandIds && data.expandIds.length > 0) {
Expand Down Expand Up @@ -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,
Expand All @@ -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 };
},
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
74 changes: 65 additions & 9 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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" &&
Expand Down Expand Up @@ -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<Session>(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": {
Expand All @@ -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 {
Expand Down
Loading