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 @@ -4,6 +4,14 @@

---

## [Unreleased]

### ✨ 改进

- **L1 作用域过滤**:`searchMemories()` 支持 `sessionKey` / `sessionId`,Gateway `/search/memories` 支持 `session_key` / `session_id`,避免共享后端里的结构化记忆跨 session 召回。

---

## [0.3.4] - 2026-05-12

### 🐛 修复
Expand Down
72 changes: 58 additions & 14 deletions src/core/store/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
IMemoryStore,
StoreCapabilities,
L0Record,
L1QueryFilter,
L1SearchResult,
L1FtsResult,
L0SearchResult,
Expand Down Expand Up @@ -96,16 +97,6 @@ export interface L0RecordRow {
timestamp: number;
}

/** Filter options for querying L1 records from SQLite. */
export interface L1QueryFilter {
/** If provided, only return records for this session key (conversation channel). */
sessionKey?: string;
/** If provided, only return records for this session ID (single conversation instance). */
sessionId?: string;
/** If provided, only return records with updated_time strictly after this ISO 8601 UTC timestamp. */
updatedAfter?: string;
}

interface Logger {
debug?: (message: string) => void;
info: (message: string) => void;
Expand Down Expand Up @@ -302,6 +293,36 @@ export function bm25RankToScore(rank: number): number {
return 1 / (1 + rank);
}

function hasL1ScopeFilter(filter?: L1QueryFilter): boolean {
return Boolean(filter?.sessionKey || filter?.sessionId);
}

function matchesL1ScopeFilter(
row: { session_key: string; session_id: string },
filter?: L1QueryFilter,
): boolean {
if (filter?.sessionKey && row.session_key !== filter.sessionKey) return false;
if (filter?.sessionId && row.session_id !== filter.sessionId) return false;
return true;
}

function buildL1ScopeSql(filter?: L1QueryFilter): {
clauses: string[];
values: string[];
} {
const clauses: string[] = [];
const values: string[] = [];
if (filter?.sessionKey) {
clauses.push("session_key = ?");
values.push(filter.sessionKey);
}
if (filter?.sessionId) {
clauses.push("session_id = ?");
values.push(filter.sessionId);
}
return { clauses, values };
}

/** FTS5 search result for L1 records. */
export interface FtsSearchResult {
record_id: string;
Expand Down Expand Up @@ -1109,7 +1130,12 @@ export class VectorStore implements IMemoryStore {
* **Fault-tolerant**: returns an empty array on any error (e.g. dimension
* mismatch, corrupted DB) so callers can fall back to keyword search.
*/
searchL1Vector(queryEmbedding: Float32Array, topK = 5): VectorSearchResult[] {
searchL1Vector(
queryEmbedding: Float32Array,
topK = 5,
_queryText?: string,
filter?: L1QueryFilter,
): VectorSearchResult[] {
if (this.degraded || !this.vecTablesReady) {
if (this.degraded) this.logger?.warn(`${TAG} [L1-search] SKIPPED (degraded mode)`);
return [];
Expand All @@ -1123,7 +1149,9 @@ export class VectorStore implements IMemoryStore {
// NOTE: "AND distance IS NOT NULL" is NOT usable because vec0 does not
// support that constraint — it causes an empty result set.
const ZERO_VEC_BUFFER = 10;
const retrieveCount = topK + ZERO_VEC_BUFFER;
const retrieveCount = hasL1ScopeFilter(filter)
? Math.max(this.countL1(), topK + ZERO_VEC_BUFFER)
: topK + ZERO_VEC_BUFFER;

this.logger?.debug?.(
`${TAG} [L1-search] START topK=${topK}, retrieveCount=${retrieveCount}, ` +
Expand Down Expand Up @@ -1171,6 +1199,7 @@ export class VectorStore implements IMemoryStore {
this.logger?.warn(`${TAG} [L1-search] record_id=${record_id} has vector but NO metadata (orphan)`);
continue;
}
if (!matchesL1ScopeFilter(meta, filter)) continue;

const score = 1.0 - distance;
this.logger?.debug?.(
Expand Down Expand Up @@ -2026,10 +2055,25 @@ export class VectorStore implements IMemoryStore {
*
* **Fault-tolerant**: returns an empty array on any error.
*/
searchL1Fts(ftsQuery: string, limit = 20): FtsSearchResult[] {
searchL1Fts(ftsQuery: string, limit = 20, filter?: L1QueryFilter): FtsSearchResult[] {
if (this.degraded || !this.ftsAvailable) return [];
try {
const rows = this.stmtL1FtsSearch.all(ftsQuery, limit) as Array<{
const scopeSql = buildL1ScopeSql(filter);
const rows = (scopeSql.clauses.length === 0
? this.stmtL1FtsSearch.all(ftsQuery, limit)
: this.db
.prepare(`
SELECT record_id, content_original AS content, type, priority, scene_name,
session_key, session_id, timestamp_str, timestamp_start, timestamp_end,
metadata_json,
bm25(l1_fts) AS rank
FROM l1_fts
WHERE l1_fts MATCH ?
AND ${scopeSql.clauses.join(" AND ")}
ORDER BY rank ASC
LIMIT ?
`)
.all(ftsQuery, ...scopeSql.values, limit)) as Array<{
record_id: string;
content: string;
type: string;
Expand Down
59 changes: 44 additions & 15 deletions src/core/store/tcvdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,25 @@ function epochMsToIso(ms: number): string {
return new Date(ms).toISOString();
}

function escapeFilterString(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}

function buildL1FilterExpression(filter?: L1QueryFilter): string | undefined {
const conditions: string[] = [];
if (filter?.sessionKey) {
conditions.push(`session_key = "${escapeFilterString(filter.sessionKey)}"`);
}
if (filter?.sessionId) {
conditions.push(`session_id = "${escapeFilterString(filter.sessionId)}"`);
}
if (filter?.updatedAfter) {
const afterMs = isoToEpochMs(filter.updatedAfter);
if (afterMs > 0) conditions.push(`updated_time_ms > ${afterMs}`);
}
return conditions.length > 0 ? conditions.join(" and ") : undefined;
}

/**
* Extract agent ID from a sessionKey like `agent:<agentId>:<channel>`.
* Returns empty string if the format doesn't match.
Expand Down Expand Up @@ -552,15 +571,7 @@ export class TcvdbMemoryStore implements IMemoryStore {
await this._ensureInit();
if (this.degraded) return [];

// Build TCVDB filter expression from L1QueryFilter
const conditions: string[] = [];
if (filter?.sessionKey) conditions.push(`session_key = "${filter.sessionKey}"`);
if (filter?.sessionId) conditions.push(`session_id = "${filter.sessionId}"`);
if (filter?.updatedAfter) {
const afterMs = isoToEpochMs(filter.updatedAfter);
if (afterMs > 0) conditions.push(`updated_time_ms > ${afterMs}`);
}
const filterExpr = conditions.length > 0 ? conditions.join(" and ") : undefined;
const filterExpr = buildL1FilterExpression(filter);

const docs = await this._queryAllDocs(
this.l1Collection,
Expand Down Expand Up @@ -615,45 +626,60 @@ export class TcvdbMemoryStore implements IMemoryStore {

// ── L1 Search Operations ─────────────────────────────────

async searchL1Vector(_queryEmbedding: Float32Array, topK?: number, queryText?: string): Promise<L1SearchResult[]> {
async searchL1Vector(
_queryEmbedding: Float32Array,
topK?: number,
queryText?: string,
filter?: L1QueryFilter,
): Promise<L1SearchResult[]> {
// TCVDB uses server-side embedding — delegate to hybrid search with text
if (queryText) {
return this.searchL1HybridAsync({ queryText, topK });
return this.searchL1HybridAsync({ queryText, topK, filter });
}
// No queryText and TCVDB can't use client embeddings directly via embeddingItems
// Return empty — callers should pass queryText for TCVDB
return [];
}

async searchL1Fts(ftsQuery: string, limit?: number): Promise<L1FtsResult[]> {
async searchL1Fts(ftsQuery: string, limit?: number, filter?: L1QueryFilter): Promise<L1FtsResult[]> {
// TCVDB has no pure FTS — use hybrid search with sparse-only path
// The ftsQuery is raw text, use it as queryText for hybrid
if (!ftsQuery) return [];
const results = await this.searchL1HybridAsync({ queryText: ftsQuery, topK: limit });
const results = await this.searchL1HybridAsync({ queryText: ftsQuery, topK: limit, filter });
// L1SearchResult and L1FtsResult have identical shapes
return results;
}

async searchL1Hybrid(params: {
query?: string;
queryEmbedding?: Float32Array;
sessionId?: string;
sessionKey?: string;
sparseVector?: SparseVector;
topK?: number;
}): Promise<L1SearchResult[]> {
const queryText = params.query;
if (!queryText) return [];
return this.searchL1HybridAsync({ queryText, topK: params.topK });
return this.searchL1HybridAsync({
queryText,
topK: params.topK,
filter: {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
},
});
}

/**
* Async L1 hybrid search — the real implementation.
* Call this directly from async contexts (hooks, tools).
*/
async searchL1HybridAsync(params: {
filter?: L1QueryFilter;
queryText: string;
topK?: number;
}): Promise<L1SearchResult[]> {
const { queryText, topK = 10 } = params;
const { filter, queryText, topK = 10 } = params;
if (!queryText) return [];

try {
Expand All @@ -665,6 +691,8 @@ export class TcvdbMemoryStore implements IMemoryStore {
limit: topK,
outputFields: L1_OUTPUT_FIELDS,
};
const filterExpr = buildL1FilterExpression(filter);
if (filterExpr) searchParams.filter = filterExpr;

// ann: use embedding field name "text" for server-side embedding
// (per SDK: AnnSearch(field_name="text", data='query string'))
Expand Down Expand Up @@ -702,6 +730,7 @@ export class TcvdbMemoryStore implements IMemoryStore {
retrieveVector: false,
outputFields: L1_OUTPUT_FIELDS,
};
if (filterExpr) denseSearch.filter = filterExpr;
const resp = await this.client.search(this.l1Collection, denseSearch);
return this._parseL1SearchResults(resp.documents);
}
Expand Down
11 changes: 9 additions & 2 deletions src/core/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,11 +270,18 @@ export interface IMemoryStore {

// ── L1 Search ────────────────────────────────────────────

searchL1Vector(queryEmbedding: Float32Array, topK?: number, queryText?: string): MaybePromise<L1SearchResult[]>;
searchL1Fts(ftsQuery: string, limit?: number): MaybePromise<L1FtsResult[]>;
searchL1Vector(
queryEmbedding: Float32Array,
topK?: number,
queryText?: string,
filter?: L1QueryFilter,
): MaybePromise<L1SearchResult[]>;
searchL1Fts(ftsQuery: string, limit?: number, filter?: L1QueryFilter): MaybePromise<L1FtsResult[]>;
searchL1Hybrid?(params: {
query?: string;
queryEmbedding?: Float32Array;
sessionId?: string;
sessionKey?: string;
sparseVector?: Array<[number, number]>;
topK?: number;
}): MaybePromise<L1SearchResult[]>;
Expand Down
2 changes: 2 additions & 0 deletions src/core/tdai-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,8 @@ export class TdaiCore {
const result = await executeMemorySearch({
query: params.query,
limit: params.limit ?? 5,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
type: params.type,
scene: params.scene,
vectorStore: this.vectorStore,
Expand Down
Loading