dsh-tdai-memory 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/THIRD-PARTY-NOTICES.md +20 -0
- package/index.js +328 -0
- package/package.json +53 -0
- package/recall-inject.js +80 -0
- package/vendor/tdai/adapters/index.js +15 -0
- package/vendor/tdai/adapters/openclaw/host-adapter.js +83 -0
- package/vendor/tdai/adapters/openclaw/index.js +5 -0
- package/vendor/tdai/adapters/openclaw/llm-runner.js +69 -0
- package/vendor/tdai/adapters/standalone/host-adapter.js +58 -0
- package/vendor/tdai/adapters/standalone/index.js +5 -0
- package/vendor/tdai/adapters/standalone/llm-runner.js +246 -0
- package/vendor/tdai/config.js +363 -0
- package/vendor/tdai/core/conversation/l0-recorder.js +420 -0
- package/vendor/tdai/core/hooks/auto-capture.js +236 -0
- package/vendor/tdai/core/hooks/auto-recall.js +653 -0
- package/vendor/tdai/core/index.js +8 -0
- package/vendor/tdai/core/persona/persona-generator.js +184 -0
- package/vendor/tdai/core/persona/persona-trigger.js +94 -0
- package/vendor/tdai/core/profile/profile-sync.js +200 -0
- package/vendor/tdai/core/prompts/l1-dedup.js +136 -0
- package/vendor/tdai/core/prompts/l1-extraction.js +132 -0
- package/vendor/tdai/core/prompts/persona-generation.js +160 -0
- package/vendor/tdai/core/prompts/scene-extraction.js +269 -0
- package/vendor/tdai/core/record/l1-dedup.js +318 -0
- package/vendor/tdai/core/record/l1-extractor.js +379 -0
- package/vendor/tdai/core/record/l1-reader.js +178 -0
- package/vendor/tdai/core/record/l1-writer.js +141 -0
- package/vendor/tdai/core/report/reporter.js +86 -0
- package/vendor/tdai/core/scene/filename-normalizer.js +161 -0
- package/vendor/tdai/core/scene/scene-extractor.js +410 -0
- package/vendor/tdai/core/scene/scene-format.js +53 -0
- package/vendor/tdai/core/scene/scene-index.js +82 -0
- package/vendor/tdai/core/scene/scene-navigation.js +60 -0
- package/vendor/tdai/core/seed/input.js +404 -0
- package/vendor/tdai/core/seed/seed-runtime.js +314 -0
- package/vendor/tdai/core/seed/types.js +9 -0
- package/vendor/tdai/core/store/bm25-client.js +117 -0
- package/vendor/tdai/core/store/bm25-local.js +68 -0
- package/vendor/tdai/core/store/embedding.js +632 -0
- package/vendor/tdai/core/store/factory.js +95 -0
- package/vendor/tdai/core/store/search-utils.js +54 -0
- package/vendor/tdai/core/store/sqlite.js +1822 -0
- package/vendor/tdai/core/store/tcvdb-client.js +221 -0
- package/vendor/tdai/core/store/tcvdb.js +1047 -0
- package/vendor/tdai/core/store/types.js +17 -0
- package/vendor/tdai/core/tdai-core.js +446 -0
- package/vendor/tdai/core/tools/conversation-search.js +199 -0
- package/vendor/tdai/core/tools/memory-search.js +207 -0
- package/vendor/tdai/core/types.js +12 -0
- package/vendor/tdai/utils/backup.js +189 -0
- package/vendor/tdai/utils/checkpoint.js +356 -0
- package/vendor/tdai/utils/clean-context-runner.js +472 -0
- package/vendor/tdai/utils/ensure-hook-policy.js +213 -0
- package/vendor/tdai/utils/env.js +13 -0
- package/vendor/tdai/utils/managed-timer.js +127 -0
- package/vendor/tdai/utils/manifest.js +91 -0
- package/vendor/tdai/utils/memory-cleaner.js +300 -0
- package/vendor/tdai/utils/no-think-fetch.js +109 -0
- package/vendor/tdai/utils/openclaw-state-dir.js +26 -0
- package/vendor/tdai/utils/pipeline-factory.js +517 -0
- package/vendor/tdai/utils/pipeline-manager.js +892 -0
- package/vendor/tdai/utils/sanitize.js +360 -0
- package/vendor/tdai/utils/serial-queue.js +103 -0
- package/vendor/tdai/utils/session-filter.js +88 -0
- package/vendor/tdai/utils/text-utils.js +29 -0
- package/vendor/tdai/utils/time.js +252 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TcvdbMemoryStore: Tencent Cloud VectorDB backend implementing IMemoryStore.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - Server-side dense embedding (embeddingItems via Collection embedding config)
|
|
6
|
+
* - Client-side sparse vectors (BM25 local encoder for hybridSearch)
|
|
7
|
+
* - Native hybridSearch (dense + sparse + RRFRerank)
|
|
8
|
+
* - Filter expressions for scalar field queries
|
|
9
|
+
* - Time fields stored as uint64 epoch ms (ISO ↔ epoch conversion internal)
|
|
10
|
+
*
|
|
11
|
+
* All methods are fault-tolerant: return empty/false on error, never throw.
|
|
12
|
+
*/
|
|
13
|
+
import { TcvdbClient, TcvdbApiError } from "./tcvdb-client.js";
|
|
14
|
+
const TAG = "[memory-tdai][tcvdb]";
|
|
15
|
+
/** Base collection suffixes (prefixed with database name at construction time). */
|
|
16
|
+
const L1_COLLECTION_SUFFIX = "l1_memories";
|
|
17
|
+
const L0_COLLECTION_SUFFIX = "l0_conversations";
|
|
18
|
+
const PROFILES_COLLECTION_SUFFIX = "profiles";
|
|
19
|
+
/** Max documents per /document/query page (VectorDB API limit). */
|
|
20
|
+
const QUERY_PAGE_SIZE = 100;
|
|
21
|
+
/** All L1 output fields returned by query/search (excludes vector/sparse_vector). */
|
|
22
|
+
const L1_OUTPUT_FIELDS = [
|
|
23
|
+
"id", "text", "type", "priority", "scene_name",
|
|
24
|
+
"session_key", "session_id", "timestamp_str", "timestamp_start",
|
|
25
|
+
"timestamp_end", "metadata_json", "created_time_ms", "updated_time_ms",
|
|
26
|
+
];
|
|
27
|
+
/** All L0 output fields returned by query/search. */
|
|
28
|
+
const L0_OUTPUT_FIELDS = [
|
|
29
|
+
"id", "message_text", "agent_id", "session_key", "session_id", "role",
|
|
30
|
+
"recorded_at_ms", "timestamp",
|
|
31
|
+
];
|
|
32
|
+
const PROFILE_OUTPUT_FIELDS = [
|
|
33
|
+
"id", "type", "filename", "content", "content_md5", "agent_id",
|
|
34
|
+
"version", "created_at_ms", "updated_at_ms",
|
|
35
|
+
];
|
|
36
|
+
const PROFILE_METADATA_OUTPUT_FIELDS = [
|
|
37
|
+
"id", "type", "filename", "content_md5", "agent_id",
|
|
38
|
+
"version", "created_at_ms", "updated_at_ms",
|
|
39
|
+
];
|
|
40
|
+
// ============================
|
|
41
|
+
// Helpers
|
|
42
|
+
// ============================
|
|
43
|
+
function isoToEpochMs(iso) {
|
|
44
|
+
if (!iso)
|
|
45
|
+
return 0;
|
|
46
|
+
const ms = new Date(iso).getTime();
|
|
47
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
48
|
+
}
|
|
49
|
+
function epochMsToIso(ms) {
|
|
50
|
+
if (!ms || ms <= 0)
|
|
51
|
+
return "";
|
|
52
|
+
return new Date(ms).toISOString();
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Extract agent ID from a sessionKey like `agent:<agentId>:<channel>`.
|
|
56
|
+
* Returns empty string if the format doesn't match.
|
|
57
|
+
*/
|
|
58
|
+
function extractAgentId(sessionKey) {
|
|
59
|
+
if (!sessionKey)
|
|
60
|
+
return "";
|
|
61
|
+
const parts = sessionKey.split(":");
|
|
62
|
+
// Format: "agent:<agentId>:..." → parts[1]
|
|
63
|
+
if (parts.length >= 2 && parts[0] === "agent") {
|
|
64
|
+
return parts[1];
|
|
65
|
+
}
|
|
66
|
+
return "";
|
|
67
|
+
}
|
|
68
|
+
// ============================
|
|
69
|
+
// TcvdbMemoryStore
|
|
70
|
+
// ============================
|
|
71
|
+
export class TcvdbMemoryStore {
|
|
72
|
+
client;
|
|
73
|
+
embeddingModel;
|
|
74
|
+
logger;
|
|
75
|
+
bm25Encoder;
|
|
76
|
+
l1Collection;
|
|
77
|
+
l0Collection;
|
|
78
|
+
profilesCollection;
|
|
79
|
+
degraded = false;
|
|
80
|
+
/** Promise that resolves when async init completes. */
|
|
81
|
+
_initPromise;
|
|
82
|
+
constructor(config) {
|
|
83
|
+
this.client = new TcvdbClient({
|
|
84
|
+
url: config.url,
|
|
85
|
+
username: config.username,
|
|
86
|
+
apiKey: config.apiKey,
|
|
87
|
+
database: config.database,
|
|
88
|
+
timeout: config.timeout,
|
|
89
|
+
caPemPath: config.caPemPath,
|
|
90
|
+
}, config.logger);
|
|
91
|
+
this.embeddingModel = config.embeddingModel;
|
|
92
|
+
this.logger = config.logger;
|
|
93
|
+
this.bm25Encoder = config.bm25Encoder;
|
|
94
|
+
// Collection names are globally unique within a TCVDB instance,
|
|
95
|
+
// so prefix with database name to avoid cross-database collisions.
|
|
96
|
+
this.l1Collection = `${config.database}_${L1_COLLECTION_SUFFIX}`;
|
|
97
|
+
this.l0Collection = `${config.database}_${L0_COLLECTION_SUFFIX}`;
|
|
98
|
+
this.profilesCollection = `${config.database}_${PROFILES_COLLECTION_SUFFIX}`;
|
|
99
|
+
}
|
|
100
|
+
// ── Lifecycle ────────────────────────────────────────────
|
|
101
|
+
async init(_providerInfo) {
|
|
102
|
+
// TCVDB init is async (HTTP). We store the promise so _ensureInit()
|
|
103
|
+
// can also await it as a defensive fallback in each data method.
|
|
104
|
+
this._initPromise = this._initAsync();
|
|
105
|
+
try {
|
|
106
|
+
await this._initPromise;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
this.logger?.error(`${TAG} Async init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
110
|
+
this.degraded = true;
|
|
111
|
+
}
|
|
112
|
+
return { needsReindex: false };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Await async initialization. Call at the start of every async method.
|
|
116
|
+
* If init already completed (or failed → degraded), returns immediately.
|
|
117
|
+
*/
|
|
118
|
+
async _ensureInit() {
|
|
119
|
+
if (this._initPromise) {
|
|
120
|
+
await this._initPromise;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
// ── Vector index definitions ─────────────────────────────
|
|
124
|
+
//
|
|
125
|
+
// Preferred: DISK_FLAT (lower memory, suitable for large-scale recall).
|
|
126
|
+
// Fallback: HNSW (for instances whose storage engine doesn't support DISK_FLAT).
|
|
127
|
+
static VECTOR_INDEX_DISK_FLAT = {
|
|
128
|
+
fieldName: "vector", fieldType: "vector", indexType: "DISK_FLAT",
|
|
129
|
+
dimension: 1024, metricType: "COSINE",
|
|
130
|
+
};
|
|
131
|
+
static VECTOR_INDEX_HNSW = {
|
|
132
|
+
fieldName: "vector", fieldType: "vector", indexType: "HNSW",
|
|
133
|
+
dimension: 1024, metricType: "COSINE",
|
|
134
|
+
params: { M: 16, efConstruction: 200 },
|
|
135
|
+
};
|
|
136
|
+
/**
|
|
137
|
+
* Detect whether a createCollection error indicates DISK_FLAT is unsupported.
|
|
138
|
+
* Matches on apiCode 15113 OR message containing "DISK_FLAT" + "not support".
|
|
139
|
+
*/
|
|
140
|
+
static isDiskFlatUnsupported(err) {
|
|
141
|
+
if (!(err instanceof TcvdbApiError))
|
|
142
|
+
return false;
|
|
143
|
+
if (err.apiCode === 15113)
|
|
144
|
+
return true;
|
|
145
|
+
const msg = err.message.toLowerCase();
|
|
146
|
+
return msg.includes("disk_flat") && (msg.includes("not support") || msg.includes("unsupported"));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Create a collection with DISK_FLAT vector index, falling back to HNSW
|
|
150
|
+
* if the storage engine doesn't support DISK_FLAT.
|
|
151
|
+
*/
|
|
152
|
+
async _createCollectionWithVectorFallback(params, filterIndexes) {
|
|
153
|
+
const buildIndexes = (vectorIndex) => [
|
|
154
|
+
{ fieldName: "id", fieldType: "string", indexType: "primaryKey" },
|
|
155
|
+
vectorIndex,
|
|
156
|
+
{ fieldName: "sparse_vector", fieldType: "sparseVector", indexType: "inverted", metricType: "IP" },
|
|
157
|
+
...filterIndexes,
|
|
158
|
+
];
|
|
159
|
+
try {
|
|
160
|
+
await this.client.createCollection({ ...params, indexes: buildIndexes(TcvdbMemoryStore.VECTOR_INDEX_DISK_FLAT) });
|
|
161
|
+
}
|
|
162
|
+
catch (err) {
|
|
163
|
+
if (TcvdbMemoryStore.isDiskFlatUnsupported(err)) {
|
|
164
|
+
this.logger?.debug?.(`${TAG} DISK_FLAT not supported for ${String(params.collection)}, falling back to HNSW`);
|
|
165
|
+
await this.client.createCollection({ ...params, indexes: buildIndexes(TcvdbMemoryStore.VECTOR_INDEX_HNSW) });
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
throw err;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async _initAsync() {
|
|
173
|
+
try {
|
|
174
|
+
// Create database (idempotent — returns true if just created, false if already existed)
|
|
175
|
+
const dbCreated = await this.client.createDatabase();
|
|
176
|
+
if (dbCreated) {
|
|
177
|
+
// TCVDB requires ~3s after database creation before collections can be created.
|
|
178
|
+
// TODO: defer collection creation to first use to avoid blocking plugin startup.
|
|
179
|
+
this.logger?.debug?.(`${TAG} Waiting 5s for database to become ready...`);
|
|
180
|
+
await new Promise((r) => setTimeout(r, 5_000));
|
|
181
|
+
}
|
|
182
|
+
// Create L1 collection (DISK_FLAT preferred, HNSW fallback)
|
|
183
|
+
await this._createCollectionWithVectorFallback({
|
|
184
|
+
collection: this.l1Collection,
|
|
185
|
+
shardNum: 1,
|
|
186
|
+
replicaNum: 2,
|
|
187
|
+
description: "L1 结构化记忆",
|
|
188
|
+
embedding: {
|
|
189
|
+
status: "enabled",
|
|
190
|
+
field: "text",
|
|
191
|
+
vectorField: "vector",
|
|
192
|
+
model: this.embeddingModel,
|
|
193
|
+
},
|
|
194
|
+
}, [
|
|
195
|
+
{ fieldName: "type", fieldType: "string", indexType: "filter" },
|
|
196
|
+
{ fieldName: "priority", fieldType: "uint64", indexType: "filter" },
|
|
197
|
+
{ fieldName: "scene_name", fieldType: "string", indexType: "filter" },
|
|
198
|
+
{ fieldName: "agent_id", fieldType: "string", indexType: "filter" },
|
|
199
|
+
{ fieldName: "session_key", fieldType: "string", indexType: "filter" },
|
|
200
|
+
{ fieldName: "session_id", fieldType: "string", indexType: "filter" },
|
|
201
|
+
{ fieldName: "timestamp_start", fieldType: "string", indexType: "filter" },
|
|
202
|
+
{ fieldName: "timestamp_end", fieldType: "string", indexType: "filter" },
|
|
203
|
+
{ fieldName: "created_time_ms", fieldType: "uint64", indexType: "filter" },
|
|
204
|
+
{ fieldName: "updated_time_ms", fieldType: "uint64", indexType: "filter" },
|
|
205
|
+
]);
|
|
206
|
+
// Create L0 collection (DISK_FLAT preferred, HNSW fallback)
|
|
207
|
+
await this._createCollectionWithVectorFallback({
|
|
208
|
+
collection: this.l0Collection,
|
|
209
|
+
shardNum: 1,
|
|
210
|
+
replicaNum: 2,
|
|
211
|
+
description: "L0 原始对话消息",
|
|
212
|
+
embedding: {
|
|
213
|
+
status: "enabled",
|
|
214
|
+
field: "message_text",
|
|
215
|
+
vectorField: "vector",
|
|
216
|
+
model: this.embeddingModel,
|
|
217
|
+
},
|
|
218
|
+
}, [
|
|
219
|
+
{ fieldName: "agent_id", fieldType: "string", indexType: "filter" },
|
|
220
|
+
{ fieldName: "session_key", fieldType: "string", indexType: "filter" },
|
|
221
|
+
{ fieldName: "session_id", fieldType: "string", indexType: "filter" },
|
|
222
|
+
{ fieldName: "role", fieldType: "string", indexType: "filter" },
|
|
223
|
+
{ fieldName: "recorded_at_ms", fieldType: "uint64", indexType: "filter" },
|
|
224
|
+
{ fieldName: "timestamp", fieldType: "int64", indexType: "filter" },
|
|
225
|
+
]);
|
|
226
|
+
await this.client.createCollection({
|
|
227
|
+
collection: this.profilesCollection,
|
|
228
|
+
shardNum: 1,
|
|
229
|
+
replicaNum: 2,
|
|
230
|
+
description: "L2 场景块 + L3 用户画像",
|
|
231
|
+
embedding: { status: "disabled" },
|
|
232
|
+
indexes: [
|
|
233
|
+
{ fieldName: "id", fieldType: "string", indexType: "primaryKey" },
|
|
234
|
+
{ fieldName: "vector", fieldType: "vector", indexType: "FLAT",
|
|
235
|
+
dimension: 1, metricType: "COSINE" },
|
|
236
|
+
{ fieldName: "type", fieldType: "string", indexType: "filter" },
|
|
237
|
+
{ fieldName: "filename", fieldType: "string", indexType: "filter" },
|
|
238
|
+
{ fieldName: "content_md5", fieldType: "string", indexType: "filter" },
|
|
239
|
+
{ fieldName: "agent_id", fieldType: "string", indexType: "filter" },
|
|
240
|
+
{ fieldName: "created_at_ms", fieldType: "uint64", indexType: "filter" },
|
|
241
|
+
{ fieldName: "updated_at_ms", fieldType: "uint64", indexType: "filter" },
|
|
242
|
+
{ fieldName: "version", fieldType: "uint64", indexType: "filter" },
|
|
243
|
+
],
|
|
244
|
+
});
|
|
245
|
+
this.logger?.debug?.(`${TAG} Initialized: db=${this.client.getDatabase()}, model=${this.embeddingModel}`);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
// 15201 = database already exists — benign race in createDatabase().
|
|
249
|
+
// 15202 (collection already exists) is now handled inside TcvdbClient.createCollection(),
|
|
250
|
+
// so it should no longer reach here.
|
|
251
|
+
if (err instanceof TcvdbApiError && err.apiCode === 15201) {
|
|
252
|
+
this.logger?.debug?.(`${TAG} Init (benign): ${err.message}`);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
this.logger?.error(`${TAG} Init failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
256
|
+
this.degraded = true;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
isDegraded() {
|
|
260
|
+
return this.degraded;
|
|
261
|
+
}
|
|
262
|
+
getCapabilities() {
|
|
263
|
+
const hasBm25 = !!this.bm25Encoder;
|
|
264
|
+
return {
|
|
265
|
+
vectorSearch: true,
|
|
266
|
+
ftsSearch: hasBm25,
|
|
267
|
+
nativeHybridSearch: hasBm25,
|
|
268
|
+
sparseVectors: hasBm25,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
close() {
|
|
272
|
+
// HTTP client — nothing to close
|
|
273
|
+
}
|
|
274
|
+
// ── Internal: paginated query helper ────────────────────
|
|
275
|
+
/**
|
|
276
|
+
* Paginated /document/query that fetches all matching docs.
|
|
277
|
+
* TCVDB query API returns at most `limit` docs per call.
|
|
278
|
+
* We loop with offset until fewer docs than page size are returned.
|
|
279
|
+
*/
|
|
280
|
+
async _queryAllDocs(collection, filter, outputFields, limit, sort) {
|
|
281
|
+
const allDocs = [];
|
|
282
|
+
let offset = 0;
|
|
283
|
+
const pageSize = limit && limit < QUERY_PAGE_SIZE ? limit : QUERY_PAGE_SIZE;
|
|
284
|
+
// eslint-disable-next-line no-constant-condition
|
|
285
|
+
while (true) {
|
|
286
|
+
const queryParams = {
|
|
287
|
+
retrieveVector: false,
|
|
288
|
+
limit: pageSize,
|
|
289
|
+
offset,
|
|
290
|
+
};
|
|
291
|
+
if (filter)
|
|
292
|
+
queryParams.filter = filter;
|
|
293
|
+
if (outputFields)
|
|
294
|
+
queryParams.outputFields = outputFields;
|
|
295
|
+
if (sort)
|
|
296
|
+
queryParams.sort = sort;
|
|
297
|
+
const resp = await this.client.query(collection, queryParams);
|
|
298
|
+
const docs = resp.documents ?? [];
|
|
299
|
+
allDocs.push(...docs);
|
|
300
|
+
// Stop if: we got fewer than page size (last page), or we hit caller's limit
|
|
301
|
+
if (docs.length < pageSize)
|
|
302
|
+
break;
|
|
303
|
+
if (limit && allDocs.length >= limit)
|
|
304
|
+
break;
|
|
305
|
+
offset += docs.length;
|
|
306
|
+
}
|
|
307
|
+
// Trim to caller's limit if specified
|
|
308
|
+
return limit ? allDocs.slice(0, limit) : allDocs;
|
|
309
|
+
}
|
|
310
|
+
// ── L1 Write Operations ──────────────────────────────────
|
|
311
|
+
async upsertL1(record, _embedding) {
|
|
312
|
+
try {
|
|
313
|
+
await this._upsertL1Async(record);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
this.logger?.warn(`${TAG} [L1-upsert] FAILED id=${record.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
async _upsertL1Async(record) {
|
|
322
|
+
await this._ensureInit();
|
|
323
|
+
if (this.degraded)
|
|
324
|
+
return;
|
|
325
|
+
const tsStr = record.timestamps[0] ?? "";
|
|
326
|
+
const tsStart = record.timestamps.length > 0
|
|
327
|
+
? record.timestamps.reduce((a, b) => (a < b ? a : b)) : tsStr;
|
|
328
|
+
const tsEnd = record.timestamps.length > 0
|
|
329
|
+
? record.timestamps.reduce((a, b) => (a > b ? a : b)) : tsStr;
|
|
330
|
+
const doc = {
|
|
331
|
+
id: record.id,
|
|
332
|
+
text: record.content,
|
|
333
|
+
type: record.type,
|
|
334
|
+
priority: record.priority,
|
|
335
|
+
scene_name: record.scene_name,
|
|
336
|
+
agent_id: extractAgentId(record.sessionKey),
|
|
337
|
+
session_key: record.sessionKey,
|
|
338
|
+
session_id: record.sessionId,
|
|
339
|
+
timestamp_str: tsStr,
|
|
340
|
+
timestamp_start: tsStart,
|
|
341
|
+
timestamp_end: tsEnd,
|
|
342
|
+
created_time_ms: isoToEpochMs(record.createdAt),
|
|
343
|
+
updated_time_ms: isoToEpochMs(record.updatedAt),
|
|
344
|
+
metadata_json: JSON.stringify(record.metadata),
|
|
345
|
+
};
|
|
346
|
+
// BM25 sparse vector (if sidecar available)
|
|
347
|
+
if (this.bm25Encoder) {
|
|
348
|
+
const sparse = this.bm25Encoder.encodeTexts([record.content]);
|
|
349
|
+
if (sparse.length > 0 && sparse[0].length > 0) {
|
|
350
|
+
doc.sparse_vector = sparse[0];
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
await this.client.upsert(this.l1Collection, [doc]);
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Batch upsert multiple L1 records in a single API call.
|
|
357
|
+
* Used by migration scripts to reduce request count.
|
|
358
|
+
*/
|
|
359
|
+
async upsertL1Batch(records) {
|
|
360
|
+
if (records.length === 0)
|
|
361
|
+
return 0;
|
|
362
|
+
try {
|
|
363
|
+
await this._ensureInit();
|
|
364
|
+
if (this.degraded)
|
|
365
|
+
return 0;
|
|
366
|
+
const docs = records.map((record) => {
|
|
367
|
+
const tsStr = record.timestamps[0] ?? "";
|
|
368
|
+
const tsStart = record.timestamps.length > 0
|
|
369
|
+
? record.timestamps.reduce((a, b) => (a < b ? a : b)) : tsStr;
|
|
370
|
+
const tsEnd = record.timestamps.length > 0
|
|
371
|
+
? record.timestamps.reduce((a, b) => (a > b ? a : b)) : tsStr;
|
|
372
|
+
const doc = {
|
|
373
|
+
id: record.id,
|
|
374
|
+
text: record.content,
|
|
375
|
+
type: record.type,
|
|
376
|
+
priority: record.priority,
|
|
377
|
+
scene_name: record.scene_name,
|
|
378
|
+
agent_id: extractAgentId(record.sessionKey),
|
|
379
|
+
session_key: record.sessionKey,
|
|
380
|
+
session_id: record.sessionId,
|
|
381
|
+
timestamp_str: tsStr,
|
|
382
|
+
timestamp_start: tsStart,
|
|
383
|
+
timestamp_end: tsEnd,
|
|
384
|
+
created_time_ms: isoToEpochMs(record.createdAt),
|
|
385
|
+
updated_time_ms: isoToEpochMs(record.updatedAt),
|
|
386
|
+
metadata_json: JSON.stringify(record.metadata),
|
|
387
|
+
};
|
|
388
|
+
if (this.bm25Encoder) {
|
|
389
|
+
const sparse = this.bm25Encoder.encodeTexts([record.content]);
|
|
390
|
+
if (sparse.length > 0 && sparse[0].length > 0) {
|
|
391
|
+
doc.sparse_vector = sparse[0];
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return doc;
|
|
395
|
+
});
|
|
396
|
+
await this.client.upsert(this.l1Collection, docs);
|
|
397
|
+
return records.length;
|
|
398
|
+
}
|
|
399
|
+
catch (err) {
|
|
400
|
+
this.logger?.warn(`${TAG} [L1-upsertBatch] FAILED (${records.length} records): ${err instanceof Error ? err.message : String(err)}`);
|
|
401
|
+
return 0;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async deleteL1(recordId) {
|
|
405
|
+
try {
|
|
406
|
+
await this._ensureInit();
|
|
407
|
+
if (this.degraded)
|
|
408
|
+
return false;
|
|
409
|
+
await this.client.deleteDoc(this.l1Collection, {
|
|
410
|
+
query: { documentIds: [recordId] },
|
|
411
|
+
});
|
|
412
|
+
return true;
|
|
413
|
+
}
|
|
414
|
+
catch (err) {
|
|
415
|
+
this.logger?.warn(`${TAG} [L1-delete] FAILED id=${recordId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
416
|
+
return false;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
async deleteL1Batch(recordIds) {
|
|
420
|
+
if (recordIds.length === 0)
|
|
421
|
+
return true;
|
|
422
|
+
try {
|
|
423
|
+
await this._ensureInit();
|
|
424
|
+
if (this.degraded)
|
|
425
|
+
return false;
|
|
426
|
+
await this.client.deleteDoc(this.l1Collection, {
|
|
427
|
+
query: { documentIds: recordIds },
|
|
428
|
+
});
|
|
429
|
+
return true;
|
|
430
|
+
}
|
|
431
|
+
catch (err) {
|
|
432
|
+
this.logger?.warn(`${TAG} [L1-deleteBatch] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async deleteL1Expired(cutoffIso) {
|
|
437
|
+
const cutoffMs = isoToEpochMs(cutoffIso);
|
|
438
|
+
if (cutoffMs <= 0)
|
|
439
|
+
return 0;
|
|
440
|
+
try {
|
|
441
|
+
await this._ensureInit();
|
|
442
|
+
if (this.degraded)
|
|
443
|
+
return 0;
|
|
444
|
+
const filter = `updated_time_ms < ${cutoffMs}`;
|
|
445
|
+
const toDelete = await this.client.count(this.l1Collection, filter);
|
|
446
|
+
if (toDelete === 0)
|
|
447
|
+
return 0;
|
|
448
|
+
const total = await this.client.count(this.l1Collection);
|
|
449
|
+
const ratio = total > 0 ? toDelete / total : 0;
|
|
450
|
+
if (ratio > 0.8) {
|
|
451
|
+
this.logger?.warn(`${TAG} [L1-deleteExpired] BLOCKED: would delete ${toDelete}/${total} ` +
|
|
452
|
+
`(${(ratio * 100).toFixed(1)}%) — exceeds 80% safety threshold, cutoff=${cutoffIso}`);
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
await this.client.deleteDoc(this.l1Collection, {
|
|
456
|
+
query: { filter },
|
|
457
|
+
});
|
|
458
|
+
this.logger?.info?.(`${TAG} [L1-deleteExpired] Deleted ~${toDelete}/${total} records (cutoff=${cutoffIso})`);
|
|
459
|
+
return toDelete;
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
this.logger?.warn(`${TAG} [L1-deleteExpired] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
463
|
+
return 0;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
// ── L1 Read Operations ───────────────────────────────────
|
|
467
|
+
async countL1() {
|
|
468
|
+
try {
|
|
469
|
+
await this._ensureInit();
|
|
470
|
+
if (this.degraded)
|
|
471
|
+
return 0;
|
|
472
|
+
return await this.client.count(this.l1Collection);
|
|
473
|
+
}
|
|
474
|
+
catch (err) {
|
|
475
|
+
this.logger?.warn(`${TAG} [L1-count] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
476
|
+
return 0;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
async queryL1Records(filter) {
|
|
480
|
+
try {
|
|
481
|
+
await this._ensureInit();
|
|
482
|
+
if (this.degraded)
|
|
483
|
+
return [];
|
|
484
|
+
// Build TCVDB filter expression from L1QueryFilter
|
|
485
|
+
const conditions = [];
|
|
486
|
+
if (filter?.sessionKey)
|
|
487
|
+
conditions.push(`session_key = "${filter.sessionKey}"`);
|
|
488
|
+
if (filter?.sessionId)
|
|
489
|
+
conditions.push(`session_id = "${filter.sessionId}"`);
|
|
490
|
+
if (filter?.updatedAfter) {
|
|
491
|
+
const afterMs = isoToEpochMs(filter.updatedAfter);
|
|
492
|
+
if (afterMs > 0)
|
|
493
|
+
conditions.push(`updated_time_ms > ${afterMs}`);
|
|
494
|
+
}
|
|
495
|
+
const filterExpr = conditions.length > 0 ? conditions.join(" and ") : undefined;
|
|
496
|
+
const docs = await this._queryAllDocs(this.l1Collection, filterExpr, L1_OUTPUT_FIELDS, undefined, // no limit — fetch all matching
|
|
497
|
+
[{ fieldName: "updated_time_ms", direction: "asc" }]);
|
|
498
|
+
return docs.map((doc) => ({
|
|
499
|
+
record_id: String(doc.id ?? ""),
|
|
500
|
+
content: String(doc.text ?? ""),
|
|
501
|
+
type: String(doc.type ?? ""),
|
|
502
|
+
priority: Number(doc.priority ?? 0),
|
|
503
|
+
scene_name: String(doc.scene_name ?? ""),
|
|
504
|
+
session_key: String(doc.session_key ?? ""),
|
|
505
|
+
session_id: String(doc.session_id ?? ""),
|
|
506
|
+
timestamp_str: String(doc.timestamp_str ?? ""),
|
|
507
|
+
timestamp_start: String(doc.timestamp_start ?? ""),
|
|
508
|
+
timestamp_end: String(doc.timestamp_end ?? ""),
|
|
509
|
+
created_time: epochMsToIso(Number(doc.created_time_ms ?? 0)),
|
|
510
|
+
updated_time: epochMsToIso(Number(doc.updated_time_ms ?? 0)),
|
|
511
|
+
metadata_json: String(doc.metadata_json ?? "{}"),
|
|
512
|
+
}));
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
this.logger?.warn(`${TAG} [L1-query] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
async getAllL1Texts() {
|
|
520
|
+
try {
|
|
521
|
+
await this._ensureInit();
|
|
522
|
+
if (this.degraded)
|
|
523
|
+
return [];
|
|
524
|
+
const docs = await this._queryAllDocs(this.l1Collection, undefined, ["id", "text", "updated_time_ms"]);
|
|
525
|
+
return docs.map((doc) => ({
|
|
526
|
+
record_id: String(doc.id ?? ""),
|
|
527
|
+
content: String(doc.text ?? ""),
|
|
528
|
+
updated_time: epochMsToIso(Number(doc.updated_time_ms ?? 0)),
|
|
529
|
+
}));
|
|
530
|
+
}
|
|
531
|
+
catch (err) {
|
|
532
|
+
this.logger?.warn(`${TAG} [L1-getAllTexts] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
533
|
+
return [];
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
// ── L1 Search Operations ─────────────────────────────────
|
|
537
|
+
async searchL1Vector(_queryEmbedding, topK, queryText) {
|
|
538
|
+
// TCVDB uses server-side embedding — delegate to hybrid search with text
|
|
539
|
+
if (queryText) {
|
|
540
|
+
return this.searchL1HybridAsync({ queryText, topK });
|
|
541
|
+
}
|
|
542
|
+
// No queryText and TCVDB can't use client embeddings directly via embeddingItems
|
|
543
|
+
// Return empty — callers should pass queryText for TCVDB
|
|
544
|
+
return [];
|
|
545
|
+
}
|
|
546
|
+
async searchL1Fts(ftsQuery, limit) {
|
|
547
|
+
// TCVDB has no pure FTS — use hybrid search with sparse-only path
|
|
548
|
+
// The ftsQuery is raw text, use it as queryText for hybrid
|
|
549
|
+
if (!ftsQuery)
|
|
550
|
+
return [];
|
|
551
|
+
const results = await this.searchL1HybridAsync({ queryText: ftsQuery, topK: limit });
|
|
552
|
+
// L1SearchResult and L1FtsResult have identical shapes
|
|
553
|
+
return results;
|
|
554
|
+
}
|
|
555
|
+
async searchL1Hybrid(params) {
|
|
556
|
+
const queryText = params.query;
|
|
557
|
+
if (!queryText)
|
|
558
|
+
return [];
|
|
559
|
+
return this.searchL1HybridAsync({ queryText, topK: params.topK });
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Async L1 hybrid search — the real implementation.
|
|
563
|
+
* Call this directly from async contexts (hooks, tools).
|
|
564
|
+
*/
|
|
565
|
+
async searchL1HybridAsync(params) {
|
|
566
|
+
const { queryText, topK = 10 } = params;
|
|
567
|
+
if (!queryText)
|
|
568
|
+
return [];
|
|
569
|
+
try {
|
|
570
|
+
await this._ensureInit();
|
|
571
|
+
if (this.degraded)
|
|
572
|
+
return [];
|
|
573
|
+
// Build search params
|
|
574
|
+
const searchParams = {
|
|
575
|
+
limit: topK,
|
|
576
|
+
outputFields: L1_OUTPUT_FIELDS,
|
|
577
|
+
};
|
|
578
|
+
// ann: use embedding field name "text" for server-side embedding
|
|
579
|
+
// (per SDK: AnnSearch(field_name="text", data='query string'))
|
|
580
|
+
const ann = [{
|
|
581
|
+
fieldName: "text",
|
|
582
|
+
data: [queryText], // embeddingItems — server-side embedding
|
|
583
|
+
limit: topK,
|
|
584
|
+
}];
|
|
585
|
+
let match;
|
|
586
|
+
if (this.bm25Encoder) {
|
|
587
|
+
const sparse = this.bm25Encoder.encodeQueries([queryText]);
|
|
588
|
+
if (sparse.length > 0 && sparse[0].length > 0) {
|
|
589
|
+
match = [{
|
|
590
|
+
fieldName: "sparse_vector",
|
|
591
|
+
data: [sparse[0]], // SDK wraps single sparse vector in array
|
|
592
|
+
limit: topK,
|
|
593
|
+
}];
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (match) {
|
|
597
|
+
// Full hybrid: dense + sparse + RRF
|
|
598
|
+
searchParams.ann = ann;
|
|
599
|
+
searchParams.match = match;
|
|
600
|
+
searchParams.rerank = { method: "rrf", k: 60 };
|
|
601
|
+
const resp = await this.client.hybridSearch(this.l1Collection, searchParams);
|
|
602
|
+
return this._parseL1SearchResults(resp.documents);
|
|
603
|
+
}
|
|
604
|
+
else {
|
|
605
|
+
// Dense-only fallback (BM25 unavailable) — use /document/search with embeddingItems
|
|
606
|
+
const denseSearch = {
|
|
607
|
+
embeddingItems: [queryText],
|
|
608
|
+
limit: topK,
|
|
609
|
+
retrieveVector: false,
|
|
610
|
+
outputFields: L1_OUTPUT_FIELDS,
|
|
611
|
+
};
|
|
612
|
+
const resp = await this.client.search(this.l1Collection, denseSearch);
|
|
613
|
+
return this._parseL1SearchResults(resp.documents);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
catch (err) {
|
|
617
|
+
this.logger?.warn(`${TAG} [L1-hybridSearch] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
618
|
+
return [];
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
// ── L0 Write Operations ──────────────────────────────────
|
|
622
|
+
async upsertL0(record, _embedding) {
|
|
623
|
+
try {
|
|
624
|
+
await this._upsertL0Async(record);
|
|
625
|
+
return true;
|
|
626
|
+
}
|
|
627
|
+
catch (err) {
|
|
628
|
+
this.logger?.warn(`${TAG} [L0-upsert] FAILED id=${record.id}: ${err instanceof Error ? err.message : String(err)}`);
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
async _upsertL0Async(record) {
|
|
633
|
+
await this._ensureInit();
|
|
634
|
+
if (this.degraded)
|
|
635
|
+
return;
|
|
636
|
+
const doc = {
|
|
637
|
+
id: record.id,
|
|
638
|
+
message_text: record.messageText,
|
|
639
|
+
agent_id: extractAgentId(record.sessionKey),
|
|
640
|
+
session_key: record.sessionKey,
|
|
641
|
+
session_id: record.sessionId,
|
|
642
|
+
role: record.role,
|
|
643
|
+
recorded_at_ms: isoToEpochMs(record.recordedAt),
|
|
644
|
+
timestamp: record.timestamp,
|
|
645
|
+
};
|
|
646
|
+
if (this.bm25Encoder) {
|
|
647
|
+
const sparse = this.bm25Encoder.encodeTexts([record.messageText]);
|
|
648
|
+
if (sparse.length > 0 && sparse[0].length > 0) {
|
|
649
|
+
doc.sparse_vector = sparse[0];
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
await this.client.upsert(this.l0Collection, [doc]);
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Batch upsert multiple L0 records in a single API call.
|
|
656
|
+
* Used by migration scripts to reduce request count.
|
|
657
|
+
*/
|
|
658
|
+
async upsertL0Batch(records) {
|
|
659
|
+
if (records.length === 0)
|
|
660
|
+
return 0;
|
|
661
|
+
try {
|
|
662
|
+
await this._ensureInit();
|
|
663
|
+
if (this.degraded)
|
|
664
|
+
return 0;
|
|
665
|
+
const docs = records.map((record) => {
|
|
666
|
+
const doc = {
|
|
667
|
+
id: record.id,
|
|
668
|
+
message_text: record.messageText,
|
|
669
|
+
agent_id: extractAgentId(record.sessionKey),
|
|
670
|
+
session_key: record.sessionKey,
|
|
671
|
+
session_id: record.sessionId,
|
|
672
|
+
role: record.role,
|
|
673
|
+
recorded_at_ms: isoToEpochMs(record.recordedAt),
|
|
674
|
+
timestamp: record.timestamp,
|
|
675
|
+
};
|
|
676
|
+
if (this.bm25Encoder) {
|
|
677
|
+
const sparse = this.bm25Encoder.encodeTexts([record.messageText]);
|
|
678
|
+
if (sparse.length > 0 && sparse[0].length > 0) {
|
|
679
|
+
doc.sparse_vector = sparse[0];
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return doc;
|
|
683
|
+
});
|
|
684
|
+
await this.client.upsert(this.l0Collection, docs);
|
|
685
|
+
return records.length;
|
|
686
|
+
}
|
|
687
|
+
catch (err) {
|
|
688
|
+
this.logger?.warn(`${TAG} [L0-upsertBatch] FAILED (${records.length} records): ${err instanceof Error ? err.message : String(err)}`);
|
|
689
|
+
return 0;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
async deleteL0(recordId) {
|
|
693
|
+
try {
|
|
694
|
+
await this._ensureInit();
|
|
695
|
+
if (this.degraded)
|
|
696
|
+
return false;
|
|
697
|
+
await this.client.deleteDoc(this.l0Collection, {
|
|
698
|
+
query: { documentIds: [recordId] },
|
|
699
|
+
});
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
catch (err) {
|
|
703
|
+
this.logger?.warn(`${TAG} [L0-delete] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
704
|
+
return false;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async deleteL0Expired(cutoffIso) {
|
|
708
|
+
const cutoffMs = isoToEpochMs(cutoffIso);
|
|
709
|
+
if (cutoffMs <= 0)
|
|
710
|
+
return 0;
|
|
711
|
+
try {
|
|
712
|
+
await this._ensureInit();
|
|
713
|
+
if (this.degraded)
|
|
714
|
+
return 0;
|
|
715
|
+
const filter = `recorded_at_ms < ${cutoffMs}`;
|
|
716
|
+
const toDelete = await this.client.count(this.l0Collection, filter);
|
|
717
|
+
if (toDelete === 0)
|
|
718
|
+
return 0;
|
|
719
|
+
const total = await this.client.count(this.l0Collection);
|
|
720
|
+
const ratio = total > 0 ? toDelete / total : 0;
|
|
721
|
+
if (ratio > 0.8) {
|
|
722
|
+
this.logger?.warn(`${TAG} [L0-deleteExpired] BLOCKED: would delete ${toDelete}/${total} ` +
|
|
723
|
+
`(${(ratio * 100).toFixed(1)}%) — exceeds 80% safety threshold, cutoff=${cutoffIso}`);
|
|
724
|
+
return 0;
|
|
725
|
+
}
|
|
726
|
+
await this.client.deleteDoc(this.l0Collection, {
|
|
727
|
+
query: { filter },
|
|
728
|
+
});
|
|
729
|
+
this.logger?.info?.(`${TAG} [L0-deleteExpired] Deleted ~${toDelete}/${total} records (cutoff=${cutoffIso})`);
|
|
730
|
+
return toDelete;
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
this.logger?.warn(`${TAG} [L0-deleteExpired] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
// ── L0 Read Operations ───────────────────────────────────
|
|
738
|
+
async countL0() {
|
|
739
|
+
try {
|
|
740
|
+
await this._ensureInit();
|
|
741
|
+
if (this.degraded)
|
|
742
|
+
return 0;
|
|
743
|
+
return await this.client.count(this.l0Collection);
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
this.logger?.warn(`${TAG} [L0-count] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
747
|
+
return 0;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
async queryL0ForL1(sessionKey, afterRecordedAtMs, limit = 50) {
|
|
751
|
+
try {
|
|
752
|
+
await this._ensureInit();
|
|
753
|
+
if (this.degraded)
|
|
754
|
+
return [];
|
|
755
|
+
const conditions = [`session_key = "${sessionKey}"`];
|
|
756
|
+
if (afterRecordedAtMs && afterRecordedAtMs > 0) {
|
|
757
|
+
conditions.push(`recorded_at_ms > ${afterRecordedAtMs}`);
|
|
758
|
+
}
|
|
759
|
+
const filterExpr = conditions.join(" and ");
|
|
760
|
+
const docs = await this._queryAllDocs(this.l0Collection, filterExpr, L0_OUTPUT_FIELDS, limit, [{ fieldName: "recorded_at_ms", direction: "desc" }]);
|
|
761
|
+
// Convert to L0QueryRow and reverse to chronological order (query is DESC, callers expect ASC)
|
|
762
|
+
const rows = docs.map((doc) => ({
|
|
763
|
+
record_id: String(doc.id ?? ""),
|
|
764
|
+
session_key: String(doc.session_key ?? ""),
|
|
765
|
+
session_id: String(doc.session_id ?? ""),
|
|
766
|
+
role: String(doc.role ?? ""),
|
|
767
|
+
message_text: String(doc.message_text ?? ""),
|
|
768
|
+
recorded_at: epochMsToIso(Number(doc.recorded_at_ms ?? 0)),
|
|
769
|
+
timestamp: Number(doc.timestamp ?? 0),
|
|
770
|
+
}));
|
|
771
|
+
return rows.reverse();
|
|
772
|
+
}
|
|
773
|
+
catch (err) {
|
|
774
|
+
this.logger?.warn(`${TAG} [L0-queryForL1] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
775
|
+
return [];
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
async queryL0GroupedBySessionId(sessionKey, afterRecordedAtMs, limit = 50) {
|
|
779
|
+
try {
|
|
780
|
+
const rows = await this.queryL0ForL1(sessionKey, afterRecordedAtMs, limit);
|
|
781
|
+
// Group by session_id
|
|
782
|
+
const groupMap = new Map();
|
|
783
|
+
for (const row of rows) {
|
|
784
|
+
const sid = row.session_id || "";
|
|
785
|
+
let group = groupMap.get(sid);
|
|
786
|
+
if (!group) {
|
|
787
|
+
group = [];
|
|
788
|
+
groupMap.set(sid, group);
|
|
789
|
+
}
|
|
790
|
+
group.push({
|
|
791
|
+
id: row.record_id,
|
|
792
|
+
role: row.role,
|
|
793
|
+
content: row.message_text,
|
|
794
|
+
timestamp: row.timestamp,
|
|
795
|
+
recordedAtMs: row.recorded_at ? Date.parse(row.recorded_at) || 0 : 0,
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
// Convert to array, sorted by earliest message timestamp
|
|
799
|
+
const groups = [];
|
|
800
|
+
for (const [sessionId, messages] of groupMap) {
|
|
801
|
+
if (messages.length > 0) {
|
|
802
|
+
groups.push({ sessionId, messages });
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
groups.sort((a, b) => a.messages[0].timestamp - b.messages[0].timestamp);
|
|
806
|
+
return groups;
|
|
807
|
+
}
|
|
808
|
+
catch (err) {
|
|
809
|
+
this.logger?.warn(`${TAG} [L0-queryGrouped] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
810
|
+
return [];
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async getAllL0Texts() {
|
|
814
|
+
try {
|
|
815
|
+
await this._ensureInit();
|
|
816
|
+
if (this.degraded)
|
|
817
|
+
return [];
|
|
818
|
+
const docs = await this._queryAllDocs(this.l0Collection, undefined, ["id", "message_text", "recorded_at_ms"]);
|
|
819
|
+
return docs.map((doc) => ({
|
|
820
|
+
record_id: String(doc.id ?? ""),
|
|
821
|
+
message_text: String(doc.message_text ?? ""),
|
|
822
|
+
recorded_at: epochMsToIso(Number(doc.recorded_at_ms ?? 0)),
|
|
823
|
+
}));
|
|
824
|
+
}
|
|
825
|
+
catch (err) {
|
|
826
|
+
this.logger?.warn(`${TAG} [L0-getAllTexts] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
827
|
+
return [];
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
// ── L0 Search Operations ─────────────────────────────────
|
|
831
|
+
async searchL0Vector(_queryEmbedding, topK, queryText) {
|
|
832
|
+
// TCVDB uses server-side embedding — delegate to hybrid search with text
|
|
833
|
+
if (queryText) {
|
|
834
|
+
return this.searchL0HybridAsync({ queryText, topK });
|
|
835
|
+
}
|
|
836
|
+
return [];
|
|
837
|
+
}
|
|
838
|
+
async searchL0Fts(ftsQuery, limit) {
|
|
839
|
+
if (!ftsQuery)
|
|
840
|
+
return [];
|
|
841
|
+
// Use hybrid search; L0SearchResult and L0FtsResult have identical shapes
|
|
842
|
+
return this.searchL0HybridAsync({ queryText: ftsQuery, topK: limit });
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Async L0 hybrid search.
|
|
846
|
+
*/
|
|
847
|
+
async searchL0HybridAsync(params) {
|
|
848
|
+
const { queryText, topK = 10 } = params;
|
|
849
|
+
if (!queryText)
|
|
850
|
+
return [];
|
|
851
|
+
try {
|
|
852
|
+
await this._ensureInit();
|
|
853
|
+
if (this.degraded)
|
|
854
|
+
return [];
|
|
855
|
+
const searchParams = {
|
|
856
|
+
limit: topK,
|
|
857
|
+
outputFields: L0_OUTPUT_FIELDS,
|
|
858
|
+
};
|
|
859
|
+
// ann: use embedding field name "message_text" for L0 server-side embedding
|
|
860
|
+
const ann = [{
|
|
861
|
+
fieldName: "message_text",
|
|
862
|
+
data: [queryText],
|
|
863
|
+
limit: topK,
|
|
864
|
+
}];
|
|
865
|
+
let match;
|
|
866
|
+
if (this.bm25Encoder) {
|
|
867
|
+
const sparse = this.bm25Encoder.encodeQueries([queryText]);
|
|
868
|
+
if (sparse.length > 0 && sparse[0].length > 0) {
|
|
869
|
+
match = [{
|
|
870
|
+
fieldName: "sparse_vector",
|
|
871
|
+
data: [sparse[0]],
|
|
872
|
+
limit: topK,
|
|
873
|
+
}];
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
if (match) {
|
|
877
|
+
searchParams.ann = ann;
|
|
878
|
+
searchParams.match = match;
|
|
879
|
+
searchParams.rerank = { method: "rrf", k: 60 };
|
|
880
|
+
const resp = await this.client.hybridSearch(this.l0Collection, searchParams);
|
|
881
|
+
return this._parseL0SearchResults(resp.documents);
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
const denseSearch = {
|
|
885
|
+
embeddingItems: [queryText],
|
|
886
|
+
limit: topK,
|
|
887
|
+
retrieveVector: false,
|
|
888
|
+
outputFields: L0_OUTPUT_FIELDS,
|
|
889
|
+
};
|
|
890
|
+
const resp = await this.client.search(this.l0Collection, denseSearch);
|
|
891
|
+
return this._parseL0SearchResults(resp.documents);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
catch (err) {
|
|
895
|
+
this.logger?.warn(`${TAG} [L0-hybridSearch] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
896
|
+
return [];
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
async pullProfiles() {
|
|
900
|
+
try {
|
|
901
|
+
await this._ensureInit();
|
|
902
|
+
if (this.degraded)
|
|
903
|
+
return [];
|
|
904
|
+
const docs = await this._queryAllDocs(this.profilesCollection, undefined, PROFILE_OUTPUT_FIELDS);
|
|
905
|
+
return docs.map((doc) => ({
|
|
906
|
+
id: String(doc.id ?? ""),
|
|
907
|
+
type: doc.type === "l3" ? "l3" : "l2",
|
|
908
|
+
filename: String(doc.filename ?? ""),
|
|
909
|
+
content: String(doc.content ?? ""),
|
|
910
|
+
contentMd5: String(doc.content_md5 ?? ""),
|
|
911
|
+
agentId: String(doc.agent_id ?? "") || undefined,
|
|
912
|
+
version: Number(doc.version ?? 0),
|
|
913
|
+
createdAtMs: Number(doc.created_at_ms ?? 0),
|
|
914
|
+
updatedAtMs: Number(doc.updated_at_ms ?? 0),
|
|
915
|
+
}));
|
|
916
|
+
}
|
|
917
|
+
catch (err) {
|
|
918
|
+
this.logger?.warn(`${TAG} [profiles-pull] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
919
|
+
return [];
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async syncProfiles(records) {
|
|
923
|
+
if (records.length === 0)
|
|
924
|
+
return;
|
|
925
|
+
try {
|
|
926
|
+
await this._ensureInit();
|
|
927
|
+
if (this.degraded)
|
|
928
|
+
return;
|
|
929
|
+
const remoteDocs = await this._queryAllDocs(this.profilesCollection, undefined, PROFILE_METADATA_OUTPUT_FIELDS);
|
|
930
|
+
const remoteMap = new Map(remoteDocs.map((doc) => [String(doc.id ?? ""), doc]));
|
|
931
|
+
const now = Date.now();
|
|
932
|
+
const upserts = [];
|
|
933
|
+
for (const record of records) {
|
|
934
|
+
const current = remoteMap.get(record.id);
|
|
935
|
+
if (!current) {
|
|
936
|
+
const createdAtMs = record.createdAtMs > 0 ? record.createdAtMs : now;
|
|
937
|
+
upserts.push({
|
|
938
|
+
id: record.id,
|
|
939
|
+
vector: [0],
|
|
940
|
+
type: record.type,
|
|
941
|
+
filename: record.filename,
|
|
942
|
+
content: record.content,
|
|
943
|
+
content_md5: record.contentMd5,
|
|
944
|
+
agent_id: record.agentId ?? "",
|
|
945
|
+
version: 1,
|
|
946
|
+
created_at_ms: createdAtMs,
|
|
947
|
+
updated_at_ms: now,
|
|
948
|
+
});
|
|
949
|
+
continue;
|
|
950
|
+
}
|
|
951
|
+
const currentMd5 = String(current.content_md5 ?? "");
|
|
952
|
+
const currentVersion = Number(current.version ?? 0);
|
|
953
|
+
const currentCreatedAtMs = Number(current.created_at_ms ?? 0) || now;
|
|
954
|
+
if (currentMd5 === record.contentMd5) {
|
|
955
|
+
continue;
|
|
956
|
+
}
|
|
957
|
+
if ((record.baselineVersion ?? 0) !== currentVersion) {
|
|
958
|
+
this.logger?.warn(`${TAG} [profiles-sync] Conflict for ${record.filename}: remote version advanced from ${record.baselineVersion ?? 0} to ${currentVersion}, skipping sync`);
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
upserts.push({
|
|
962
|
+
id: record.id,
|
|
963
|
+
vector: [0],
|
|
964
|
+
type: record.type,
|
|
965
|
+
filename: record.filename,
|
|
966
|
+
content: record.content,
|
|
967
|
+
content_md5: record.contentMd5,
|
|
968
|
+
agent_id: record.agentId ?? "",
|
|
969
|
+
version: currentVersion + 1,
|
|
970
|
+
created_at_ms: currentCreatedAtMs,
|
|
971
|
+
updated_at_ms: now,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
if (upserts.length > 0) {
|
|
975
|
+
await this.client.upsert(this.profilesCollection, upserts);
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
catch (err) {
|
|
979
|
+
this.logger?.warn(`${TAG} [profiles-sync] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
async deleteProfiles(recordIds) {
|
|
983
|
+
if (recordIds.length === 0)
|
|
984
|
+
return;
|
|
985
|
+
try {
|
|
986
|
+
await this._ensureInit();
|
|
987
|
+
if (this.degraded)
|
|
988
|
+
return;
|
|
989
|
+
await this.client.deleteDoc(this.profilesCollection, {
|
|
990
|
+
query: { documentIds: recordIds },
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
catch (err) {
|
|
994
|
+
this.logger?.warn(`${TAG} [profiles-delete] FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
// ── Re-index ─────────────────────────────────────────────
|
|
998
|
+
async reindexAll(_embedFn, _onProgress) {
|
|
999
|
+
// TCVDB uses server-side embedding — reindex means rebuild Collection.
|
|
1000
|
+
// Not implemented in Phase 2-3 (requires drop + recreate + re-upsert from JSONL).
|
|
1001
|
+
this.logger?.info(`${TAG} reindexAll: TCVDB uses server-side embedding, skipping`);
|
|
1002
|
+
return { l1Count: 0, l0Count: 0 };
|
|
1003
|
+
}
|
|
1004
|
+
isFtsAvailable() {
|
|
1005
|
+
return !!this.bm25Encoder;
|
|
1006
|
+
}
|
|
1007
|
+
// ── Internal: parse search results ───────────────────────
|
|
1008
|
+
_parseL1SearchResults(docArrays) {
|
|
1009
|
+
const results = [];
|
|
1010
|
+
// hybridSearch/search returns [[doc, doc, ...]] (one array per query)
|
|
1011
|
+
const docs = docArrays?.[0] ?? [];
|
|
1012
|
+
for (const doc of docs) {
|
|
1013
|
+
results.push({
|
|
1014
|
+
record_id: String(doc.id ?? ""),
|
|
1015
|
+
content: String(doc.text ?? ""),
|
|
1016
|
+
type: String(doc.type ?? ""),
|
|
1017
|
+
priority: Number(doc.priority ?? 0),
|
|
1018
|
+
scene_name: String(doc.scene_name ?? ""),
|
|
1019
|
+
score: Number(doc.score ?? 0),
|
|
1020
|
+
timestamp_str: String(doc.timestamp_str ?? ""),
|
|
1021
|
+
timestamp_start: String(doc.timestamp_start ?? ""),
|
|
1022
|
+
timestamp_end: String(doc.timestamp_end ?? ""),
|
|
1023
|
+
session_key: String(doc.session_key ?? ""),
|
|
1024
|
+
session_id: String(doc.session_id ?? ""),
|
|
1025
|
+
metadata_json: String(doc.metadata_json ?? "{}"),
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
return results;
|
|
1029
|
+
}
|
|
1030
|
+
_parseL0SearchResults(docArrays) {
|
|
1031
|
+
const results = [];
|
|
1032
|
+
const docs = docArrays?.[0] ?? [];
|
|
1033
|
+
for (const doc of docs) {
|
|
1034
|
+
results.push({
|
|
1035
|
+
record_id: String(doc.id ?? ""),
|
|
1036
|
+
session_key: String(doc.session_key ?? ""),
|
|
1037
|
+
session_id: String(doc.session_id ?? ""),
|
|
1038
|
+
role: String(doc.role ?? ""),
|
|
1039
|
+
message_text: String(doc.message_text ?? ""),
|
|
1040
|
+
score: Number(doc.score ?? 0),
|
|
1041
|
+
recorded_at: epochMsToIso(Number(doc.recorded_at_ms ?? 0)),
|
|
1042
|
+
timestamp: Number(doc.timestamp ?? 0),
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
return results;
|
|
1046
|
+
}
|
|
1047
|
+
}
|