@shadowforge0/aquifer-memory 0.5.0 → 0.7.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/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **PG-native long-term memory for AI agents**
6
6
 
7
- *Turn-level embedding, hybrid RRF ranking, trust scoring, entity intersection, knowledge graph — all on PostgreSQL + pgvector.*
7
+ *Turn-level embedding, hybrid RRF ranking, trust scoring, entity intersection, knowledge graph, entity scoping — all on PostgreSQL + pgvector.*
8
8
 
9
9
  [![npm version](https://img.shields.io/npm/v/@shadowforge0/aquifer-memory)](https://www.npmjs.com/package/@shadowforge0/aquifer-memory)
10
10
  [![PostgreSQL 15+](https://img.shields.io/badge/PostgreSQL-15%2B-336791)](https://www.postgresql.org/)
@@ -68,19 +68,19 @@ npm install @shadowforge0/aquifer-memory
68
68
  const { createAquifer } = require('@shadowforge0/aquifer-memory');
69
69
 
70
70
  const aquifer = createAquifer({
71
+ db: 'postgresql://user:pass@localhost:5432/mydb', // connection string or pg.Pool
71
72
  schema: 'memory', // PG schema name (default: 'aquifer')
72
- pg: {
73
- connectionString: 'postgresql://user:pass@localhost:5432/mydb',
74
- },
75
- embedder: {
76
- baseURL: 'http://localhost:11434/v1', // Ollama
77
- model: 'bge-m3',
78
- apiKey: 'ollama',
73
+ tenantId: 'default', // multi-tenant isolation
74
+ embed: {
75
+ fn: async (texts) => embeddings, // your embedding function
76
+ dim: 1024, // optional dimension hint
79
77
  },
80
78
  llm: {
81
- baseURL: 'https://api.openai.com/v1',
82
- model: 'gpt-4o-mini',
83
- apiKey: process.env.OPENAI_API_KEY,
79
+ fn: async (prompt) => text, // your LLM function (for built-in summarize)
80
+ },
81
+ entities: {
82
+ enabled: true,
83
+ scope: 'my-app', // entity namespace (default: 'default')
84
84
  },
85
85
  });
86
86
 
@@ -88,26 +88,36 @@ const aquifer = createAquifer({
88
88
  await aquifer.migrate();
89
89
  ```
90
90
 
91
- ### Ingest a session
91
+ ### Write path: commit + enrich
92
92
 
93
93
  ```javascript
94
- await aquifer.ingest({
95
- sessionId: 'conv-001',
94
+ // 1. Store the session
95
+ await aquifer.commit('conv-001', [
96
+ { role: 'user', content: 'Let me tell you about our new auth approach...' },
97
+ { role: 'assistant', content: 'Got it. So the plan is...' },
98
+ ], { agentId: 'main' });
99
+
100
+ // 2. Enrich: summarize + embed turns + extract entities
101
+ const result = await aquifer.enrich('conv-001', {
96
102
  agentId: 'main',
97
- messages: [
98
- { role: 'user', content: 'Let me tell you about our new auth approach...' },
99
- { role: 'assistant', content: 'Got it. So the plan is...' },
100
- ],
103
+ // Optional: bring your own summarize pipeline
104
+ summaryFn: async (msgs) => ({ summaryText, structuredSummary, entityRaw }),
105
+ entityParseFn: (text) => [{ name, normalizedName, type, aliases }],
106
+ // Optional: post-commit hook for downstream processing
107
+ postProcess: async (ctx) => {
108
+ // ctx contains session, summary, embedding, parsedEntities, etc.
109
+ },
101
110
  });
102
- // Stores session → generates summary → creates turn embeddings → extracts entities
103
111
  ```
104
112
 
105
- ### Recall
113
+ ### Read path: recall
106
114
 
107
115
  ```javascript
108
116
  const results = await aquifer.recall('auth middleware decision', {
109
117
  agentId: 'main',
110
118
  limit: 5,
119
+ entities: ['auth-middleware'], // optional: entity-aware search
120
+ entityMode: 'all', // 'any' (boost) or 'all' (hard filter)
111
121
  });
112
122
  // Returns ranked sessions with scores, using 3-way RRF fusion
113
123
  ```
@@ -257,109 +267,150 @@ Pass `schema: 'my_app'` to `createAquifer()` and all tables live under that Post
257
267
 
258
268
  ### `createAquifer(config)`
259
269
 
260
- Returns an Aquifer instance with the following methods:
270
+ Returns an Aquifer instance. Config:
271
+
272
+ ```javascript
273
+ {
274
+ db, // pg connection string or Pool instance (required)
275
+ schema, // PG schema name (default: 'aquifer')
276
+ tenantId, // multi-tenant key (default: 'default')
277
+ embed: { fn, dim }, // embedding function (required for recall)
278
+ llm: { fn }, // LLM function (required for built-in summarize)
279
+ entities: {
280
+ enabled, // enable KG (default: false)
281
+ scope, // entity namespace (default: 'default')
282
+ mergeCall, // merge entity extraction into summary LLM call (default: true)
283
+ },
284
+ rank: { rrf, timeDecay, access, entityBoost }, // weight overrides
285
+ }
286
+ ```
261
287
 
262
288
  #### `aquifer.migrate()`
263
289
 
264
- Runs SQL migrations (idempotent). Creates tables, indexes, and extensions.
290
+ Runs SQL migrations (idempotent). Creates tables, indexes, triggers, and extensions.
265
291
 
266
- #### `aquifer.ingest(options)`
292
+ #### `aquifer.commit(sessionId, messages, opts)`
267
293
 
268
- Ingests a session: stores messages, generates summary, creates turn embeddings, extracts entities.
294
+ Stores a session. Returns `{ id, sessionId, isNew }`.
269
295
 
270
296
  ```javascript
271
- await aquifer.ingest({
272
- sessionId: 'unique-id',
297
+ await aquifer.commit('session-001', messages, {
273
298
  agentId: 'main',
274
- source: 'api', // optional, default 'api'
275
- messages: [{ role, content }],
276
- tenantId: 'default', // optional
277
- model: 'gpt-4o', // optional metadata
278
- tokensIn: 1500, // optional
279
- tokensOut: 800, // optional
299
+ source: 'api',
300
+ sessionKey: 'optional-key',
301
+ model: 'gpt-4o',
302
+ tokensIn: 1500,
303
+ tokensOut: 800,
304
+ startedAt: isoString,
305
+ lastMessageAt: isoString,
280
306
  });
281
307
  ```
282
308
 
283
- #### `aquifer.recall(query, options)`
309
+ #### `aquifer.enrich(sessionId, opts)`
284
310
 
285
- Hybrid search across sessions.
311
+ Enriches a committed session: summarize, embed turns, extract entities. Uses optimistic locking with stale-reclaim (sessions stuck processing > 10 min are reclaimable).
312
+
313
+ ```javascript
314
+ const result = await aquifer.enrich('session-001', {
315
+ agentId: 'main',
316
+ summaryFn, // custom summarize pipeline (bypasses built-in LLM)
317
+ entityParseFn, // custom entity parser
318
+ postProcess, // async callback after tx commit
319
+ model: 'override', // model metadata override
320
+ skipSummary: false,
321
+ skipTurnEmbed: false,
322
+ skipEntities: false,
323
+ });
324
+ // Returns: { summary, turnsEmbedded, entitiesFound, warnings, effectiveModel, postProcessError }
325
+ ```
326
+
327
+ **postProcess hook**: runs after transaction commit, receives full context (session, summary, embedding, parsedEntities, etc.). Best-effort, at-most-once.
328
+
329
+ #### `aquifer.recall(query, opts)`
330
+
331
+ Hybrid search across sessions using 3-way RRF.
286
332
 
287
333
  ```javascript
288
334
  const results = await aquifer.recall('search query', {
289
335
  agentId: 'main',
290
- limit: 10, // max results
291
- entities: ['postgres', 'migration'], // optional: explicit entity names
336
+ limit: 10,
337
+ entities: ['postgres', 'migration'],
292
338
  entityMode: 'all', // 'any' (default) or 'all'
293
- weights: { // optional: override ranking weights
294
- rrf: 0.65,
295
- timeDecay: 0.25,
296
- access: 0.10,
297
- entityBoost: 0.18,
298
- openLoop: 0.08,
299
- },
339
+ weights: { rrf, timeDecay, access, entityBoost },
300
340
  });
301
341
  // Returns: [{ sessionId, score, trustScore, summaryText, matchedTurnText, _debug, ... }]
302
342
  ```
303
343
 
304
- #### `aquifer.feedback(sessionId, options)`
344
+ #### `aquifer.feedback(sessionId, opts)`
305
345
 
306
- Records explicit trust feedback on a session.
346
+ Records trust feedback. Returns `{ trustBefore, trustAfter, verdict }`.
307
347
 
308
348
  ```javascript
309
349
  await aquifer.feedback('session-id', {
310
350
  verdict: 'helpful', // or 'unhelpful'
311
- agentId: 'main', // optional
312
- note: 'reason', // optional
351
+ agentId: 'main',
352
+ note: 'reason',
313
353
  });
314
- // Returns: { trustBefore, trustAfter, verdict }
315
354
  ```
316
355
 
317
- #### `aquifer.enrich(sessionId, options)`
318
-
319
- Re-processes an existing session: regenerate summary, embeddings, and entities.
320
-
321
356
  #### `aquifer.close()`
322
357
 
323
- Closes the PostgreSQL connection pool.
358
+ Closes the PostgreSQL connection pool (only if Aquifer created it).
324
359
 
325
360
  ---
326
361
 
327
362
  ## Configuration
328
363
 
364
+ Aquifer takes a `db` connection (string or `pg.Pool`), plus optional `embed` and `llm` functions:
365
+
329
366
  ```javascript
330
367
  createAquifer({
331
- // PostgreSQL schema name (all tables created under this schema)
332
- schema: 'aquifer',
333
-
334
- // PostgreSQL connection
335
- pg: {
336
- connectionString: 'postgresql://...',
337
- // or individual: host, port, database, user, password
338
- max: 10, // pool size
339
- },
340
-
341
- // Embedding provider (any OpenAI-compatible API)
342
- embedder: {
343
- baseURL: 'http://localhost:11434/v1',
344
- model: 'bge-m3',
345
- apiKey: 'ollama',
346
- dimensions: 1024, // optional
347
- timeout: 30000, // ms, default 30s
368
+ db: 'postgresql://user:pass@localhost/mydb', // or an existing pg.Pool
369
+ schema: 'aquifer', // PG schema (default: 'aquifer')
370
+ tenantId: 'default', // multi-tenant key
371
+ embed: {
372
+ fn: myEmbedFn, // async (texts: string[]) => number[][]
373
+ dim: 1024, // optional dimension hint
348
374
  },
349
-
350
- // LLM for summarization & entity extraction
351
375
  llm: {
352
- baseURL: 'https://api.openai.com/v1',
353
- model: 'gpt-4o-mini',
354
- apiKey: process.env.OPENAI_API_KEY,
355
- timeout: 60000, // ms, default 60s
376
+ fn: myLlmFn, // async (prompt: string) => string
377
+ },
378
+ entities: {
379
+ enabled: true, // enable KG (default: false)
380
+ scope: 'my-app', // entity namespace — decoupled from agentId
381
+ mergeCall: true, // merge entity extraction into summary prompt
382
+ },
383
+ rank: {
384
+ rrf: 0.65, // FTS + embedding fusion weight
385
+ timeDecay: 0.25, // recency weight
386
+ access: 0.10, // access frequency weight
387
+ entityBoost: 0.18, // entity match boost
356
388
  },
357
-
358
- // Tenant isolation
359
- tenantId: 'default',
360
389
  });
361
390
  ```
362
391
 
392
+ ### Entity Scope
393
+
394
+ `entities.scope` defines the namespace for entity identity. The unique constraint is `(tenant_id, normalized_name, entity_scope)` — the same entity name in different scopes creates separate entities. This decouples entity identity from `agentId`, allowing multiple agents to share an entity namespace.
395
+
396
+ Fallback chain: `config.entities.scope` → `'default'`.
397
+
398
+ ### Consumers (CLI, MCP, OpenClaw plugin)
399
+
400
+ For consumer-based setup using environment variables instead of code:
401
+
402
+ ```bash
403
+ export DATABASE_URL="postgresql://..."
404
+ export AQUIFER_EMBED_BASE_URL="http://localhost:11434/v1"
405
+ export AQUIFER_EMBED_MODEL="bge-m3"
406
+ export AQUIFER_ENTITIES_ENABLED=true
407
+
408
+ aquifer migrate
409
+ aquifer recall "search query" --limit 5
410
+ aquifer backfill --concurrency 3
411
+ aquifer stats --json
412
+ ```
413
+
363
414
  ---
364
415
 
365
416
  ## Database Schema
@@ -378,12 +429,12 @@ Key indexes: GIN on messages, GiST on `tsvector`, ivfflat on embeddings, B-tree
378
429
 
379
430
  | Table | Purpose |
380
431
  |-------|---------|
381
- | `entities` | Normalized named entities with type, aliases, frequency, optional embedding |
432
+ | `entities` | Normalized named entities with type, aliases, frequency, entity_scope, optional embedding |
382
433
  | `entity_mentions` | Entity × session join with mention count and context |
383
434
  | `entity_relations` | Co-occurrence edges (undirected, `CHECK src < dst`) |
384
435
  | `entity_sessions` | Entity-session association for boost scoring |
385
436
 
386
- Key indexes: trigram on entity names, GiST on embeddings, composite on tenant/agent.
437
+ Key indexes: trigram on entity names, GiST on embeddings, unique on `(tenant_id, normalized_name, entity_scope)`.
387
438
 
388
439
  ### 003-trust-feedback.sql
389
440
 
package/consumers/cli.js CHANGED
@@ -344,7 +344,12 @@ Options:
344
344
  }
345
345
  }
346
346
 
347
- main().catch(err => {
348
- console.error(`aquifer: ${err.message}`);
349
- process.exit(1);
350
- });
347
+ // Export for testing; execute only when run directly
348
+ module.exports = { parseArgs };
349
+
350
+ if (require.main === module) {
351
+ main().catch(err => {
352
+ console.error(`aquifer: ${err.message}`);
353
+ process.exit(1);
354
+ });
355
+ }
package/consumers/mcp.js CHANGED
@@ -69,7 +69,7 @@ async function main() {
69
69
 
70
70
  const server = new McpServer({
71
71
  name: 'aquifer-memory',
72
- version: '0.3.1',
72
+ version: '0.6.0',
73
73
  });
74
74
 
75
75
  server.tool(
@@ -29,8 +29,19 @@ const DEFAULTS = {
29
29
  maxRetries: 3,
30
30
  temperature: 0,
31
31
  },
32
- entities: { enabled: false, mergeCall: true },
32
+ entities: { enabled: false, mergeCall: true, scope: 'default' },
33
33
  rank: { rrf: 0.65, timeDecay: 0.25, access: 0.10, entityBoost: 0.18 },
34
+ rerank: {
35
+ enabled: false,
36
+ provider: null, // 'tei' | 'jina' | 'custom'
37
+ baseUrl: null, // TEI base URL
38
+ apiKey: null, // Jina API key
39
+ model: null, // Jina model override
40
+ topK: 20,
41
+ maxChars: 1600,
42
+ timeoutMs: 2000,
43
+ maxRetries: 1,
44
+ },
34
45
  };
35
46
 
36
47
  // ---------------------------------------------------------------------------
@@ -57,6 +68,15 @@ const ENV_MAP = [
57
68
  ['AQUIFER_LLM_TIMEOUT_MS', 'llm.timeoutMs', Number],
58
69
  ['AQUIFER_LLM_TEMPERATURE', 'llm.temperature', Number],
59
70
  ['AQUIFER_ENTITIES_ENABLED', 'entities.enabled', Boolean],
71
+ ['AQUIFER_ENTITY_SCOPE', 'entities.scope'],
72
+ ['AQUIFER_RERANK_ENABLED', 'rerank.enabled', Boolean],
73
+ ['AQUIFER_RERANK_PROVIDER', 'rerank.provider'],
74
+ ['AQUIFER_RERANK_BASE_URL', 'rerank.baseUrl'],
75
+ ['AQUIFER_RERANK_API_KEY', 'rerank.apiKey'],
76
+ ['AQUIFER_RERANK_MODEL', 'rerank.model'],
77
+ ['AQUIFER_RERANK_TOP_K', 'rerank.topK', Number],
78
+ ['AQUIFER_RERANK_MAX_CHARS', 'rerank.maxChars', Number],
79
+ ['AQUIFER_RERANK_TIMEOUT_MS','rerank.timeoutMs', Number],
60
80
  ];
61
81
 
62
82
  // ---------------------------------------------------------------------------
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { Pool } = require('pg');
4
- const { createAquifer, createEmbedder } = require('../../index');
4
+ const { createAquifer, createEmbedder, createReranker } = require('../../index');
5
5
  const { loadConfig } = require('./config');
6
6
  const { createLlmFn } = require('./llm');
7
7
 
@@ -57,6 +57,24 @@ function createAquiferFromConfig(overrides) {
57
57
  llmFn = createLlmFn(config.llm);
58
58
  }
59
59
 
60
+ // Rerank config (optional)
61
+ let rerankOpts = null;
62
+ if (config.rerank && config.rerank.enabled && config.rerank.provider) {
63
+ const rc = config.rerank;
64
+ const rerankConfig = { provider: rc.provider, topK: rc.topK, maxChars: rc.maxChars };
65
+ if (rc.provider === 'tei') {
66
+ rerankConfig.teiBaseUrl = rc.baseUrl || 'http://localhost:8080';
67
+ rerankConfig.timeout = rc.timeoutMs || 2000;
68
+ rerankConfig.maxRetries = rc.maxRetries ?? 1;
69
+ } else if (rc.provider === 'jina') {
70
+ rerankConfig.jinaApiKey = rc.apiKey;
71
+ if (rc.model) rerankConfig.jinaModel = rc.model;
72
+ rerankConfig.timeout = rc.timeoutMs || 2000;
73
+ rerankConfig.maxRetries = rc.maxRetries ?? 1;
74
+ }
75
+ rerankOpts = rerankConfig;
76
+ }
77
+
60
78
  const aquifer = createAquifer({
61
79
  db: pool,
62
80
  schema: config.schema,
@@ -65,6 +83,7 @@ function createAquiferFromConfig(overrides) {
65
83
  llm: llmFn ? { fn: llmFn } : null,
66
84
  entities: config.entities,
67
85
  rank: config.rank,
86
+ rerank: rerankOpts,
68
87
  });
69
88
 
70
89
  // Attach pool for lifecycle management
package/core/aquifer.js CHANGED
@@ -36,6 +36,24 @@ function loadSql(filename, schema) {
36
36
  return raw.replace(/\$\{schema\}/g, qi(schema));
37
37
  }
38
38
 
39
+ // ---------------------------------------------------------------------------
40
+ // buildRerankDocument — assemble text for cross-encoder reranking
41
+ // ---------------------------------------------------------------------------
42
+
43
+ function buildRerankDocument(row, maxChars) {
44
+ let text = (row.summary_text || row.summary_snippet || '').replace(/\s+/g, ' ').trim();
45
+ const turn = (row.matched_turn_text || '').replace(/\s+/g, ' ').trim();
46
+
47
+ if (!text) {
48
+ text = turn;
49
+ } else if (turn && !text.includes(turn)) {
50
+ text = `${text}\n\nMatched turn:\n${turn}`;
51
+ }
52
+
53
+ if (text.length > maxChars) text = text.slice(0, maxChars);
54
+ return text;
55
+ }
56
+
39
57
  // ---------------------------------------------------------------------------
40
58
  // createAquifer
41
59
  // ---------------------------------------------------------------------------
@@ -79,6 +97,10 @@ function createAquifer(config) {
79
97
  let entitiesEnabled = config.entities && config.entities.enabled === true;
80
98
  const mergeCall = config.entities && config.entities.mergeCall !== undefined ? config.entities.mergeCall : true;
81
99
  const entityPromptFn = config.entities && config.entities.prompt ? config.entities.prompt : null;
100
+ const entityScope = (config.entities && config.entities.scope) || 'default';
101
+
102
+ // FTS config (default: 'simple'; set to 'zhcfg' for Chinese tokenization)
103
+ const ftsConfig = config.ftsConfig || 'simple';
82
104
 
83
105
  // Rank weights
84
106
  const rankWeights = {
@@ -89,6 +111,16 @@ function createAquifer(config) {
89
111
  ...(config.rank || {}),
90
112
  };
91
113
 
114
+ // Reranker config (optional)
115
+ const rerankConfig = config.rerank || null;
116
+ let reranker = null;
117
+ if (rerankConfig) {
118
+ const { createReranker } = require('../pipeline/rerank');
119
+ reranker = createReranker(rerankConfig);
120
+ }
121
+ const defaultRerankTopK = rerankConfig ? Math.max(1, rerankConfig.topK || 20) : 0;
122
+ const rerankMaxChars = rerankConfig ? Math.max(200, rerankConfig.maxChars || 1600) : 0;
123
+
92
124
  // Source registry (in-memory)
93
125
  const sources = new Map();
94
126
 
@@ -105,7 +137,7 @@ function createAquifer(config) {
105
137
 
106
138
  // --- Helper: embed search on summaries ---
107
139
  async function embeddingSearchSummaries(queryVec, opts) {
108
- const { agentId, source, dateFrom, dateTo, limit = 20 } = opts;
140
+ const { agentIds, source, dateFrom, dateTo, limit = 20 } = opts;
109
141
  const where = [`s.tenant_id = $1`];
110
142
  const params = [tenantId];
111
143
 
@@ -120,9 +152,9 @@ function createAquifer(config) {
120
152
  params.push(dateTo);
121
153
  where.push(`($${params.length}::date IS NULL OR s.started_at::date <= $${params.length}::date)`);
122
154
  }
123
- if (agentId) {
124
- params.push(agentId);
125
- where.push(`s.agent_id = $${params.length}`);
155
+ if (agentIds && agentIds.length > 0) {
156
+ params.push(agentIds);
157
+ where.push(`s.agent_id = ANY($${params.length})`);
126
158
  }
127
159
  if (source) {
128
160
  params.push(source);
@@ -264,11 +296,14 @@ function createAquifer(config) {
264
296
  const optModel = 'model' in opts ? opts.model : undefined; // undefined = no override
265
297
 
266
298
  // 1. Optimistic lock: claim session for processing
299
+ // Also reclaim stale 'processing' sessions (stuck > 10 min = likely killed process)
300
+ const STALE_MINUTES = 10;
267
301
  const claimResult = await pool.query(
268
302
  `UPDATE ${qi(schema)}.sessions
269
- SET processing_status = 'processing'
303
+ SET processing_status = 'processing', processing_started_at = NOW()
270
304
  WHERE session_id = $1 AND agent_id = $2 AND tenant_id = $3
271
- AND processing_status IN ('pending', 'failed')
305
+ AND (processing_status IN ('pending', 'failed')
306
+ OR (processing_status = 'processing' AND (processing_started_at IS NULL OR processing_started_at < NOW() - INTERVAL '${STALE_MINUTES} minutes')))
272
307
  RETURNING *`,
273
308
  [sessionId, agentId, tenantId]
274
309
  );
@@ -398,6 +433,7 @@ function createAquifer(config) {
398
433
  aliases: ent.aliases,
399
434
  type: ent.type,
400
435
  agentId,
436
+ entityScope,
401
437
  createdBy: 'aquifer',
402
438
  occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
403
439
  });
@@ -519,6 +555,7 @@ function createAquifer(config) {
519
555
 
520
556
  const {
521
557
  agentId,
558
+ agentIds: rawAgentIds,
522
559
  source,
523
560
  dateFrom,
524
561
  dateTo,
@@ -528,6 +565,12 @@ function createAquifer(config) {
528
565
  entityMode = 'any',
529
566
  } = opts;
530
567
 
568
+ // Normalize agentId/agentIds into a single resolved value
569
+ // agentIds takes precedence; agentId is sugar for agentIds: [agentId]
570
+ const resolvedAgentIds = rawAgentIds && rawAgentIds.length > 0
571
+ ? rawAgentIds
572
+ : (agentId ? [agentId] : null);
573
+
531
574
  // Validate before touching DB
532
575
  if (explicitEntities && explicitEntities.length > 0 && !entitiesEnabled) {
533
576
  throw new Error('Entities are not enabled');
@@ -535,7 +578,9 @@ function createAquifer(config) {
535
578
 
536
579
  await ensureMigrated();
537
580
 
538
- const fetchLimit = limit * 4;
581
+ const rerankEnabled = !!reranker && opts.rerank !== false;
582
+ const rerankTopK = rerankEnabled ? Math.max(limit, opts.rerankTopK || defaultRerankTopK) : limit;
583
+ const fetchLimit = rerankTopK * 4;
539
584
 
540
585
  // 1. Embed query
541
586
  const queryVecResult = await embedFn([query]);
@@ -549,7 +594,7 @@ function createAquifer(config) {
549
594
  if (explicitEntities && explicitEntities.length > 0) {
550
595
 
551
596
  const resolved = await entity.resolveEntities(pool, {
552
- schema, tenantId, names: explicitEntities, agentId,
597
+ schema, tenantId, names: explicitEntities, entityScope,
553
598
  });
554
599
 
555
600
  if (resolved.length === 0) return [];
@@ -594,7 +639,7 @@ function createAquifer(config) {
594
639
  // No explicit entities: existing query-text-based entity boost
595
640
  try {
596
641
  const matchedEntities = await entity.searchEntities(pool, {
597
- schema, tenantId, query, agentId, limit: 10,
642
+ schema, tenantId, query, entityScope, limit: 10,
598
643
  });
599
644
 
600
645
  if (matchedEntities.length > 0) {
@@ -619,13 +664,13 @@ function createAquifer(config) {
619
664
  // 3. Run 3 search paths in parallel
620
665
  const [ftsRows, embRows, turnResult] = await Promise.all([
621
666
  storage.searchSessions(pool, query, {
622
- schema, tenantId, agentId, source, dateFrom, dateTo, limit: fetchLimit,
667
+ schema, tenantId, agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit, ftsConfig,
623
668
  }).catch(() => []),
624
669
  embeddingSearchSummaries(queryVec, {
625
- agentId, source, dateFrom, dateTo, limit: fetchLimit,
670
+ agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit,
626
671
  }).catch(() => []),
627
672
  storage.searchTurnEmbeddings(pool, {
628
- schema, tenantId, queryVec, dateFrom, dateTo, agentId, source, limit: fetchLimit,
673
+ schema, tenantId, queryVec, dateFrom, dateTo, agentIds: resolvedAgentIds, source, limit: fetchLimit,
629
674
  }).catch(() => ({ rows: [] })),
630
675
  ]);
631
676
 
@@ -686,15 +731,45 @@ function createAquifer(config) {
686
731
  [...filteredEmb, ...filterFn(externalRows)],
687
732
  filteredTurn,
688
733
  {
689
- limit,
734
+ limit: rerankTopK,
690
735
  weights: mergedWeights,
691
736
  entityScoreBySession,
692
737
  openLoopSet,
693
738
  },
694
739
  );
695
740
 
741
+ // 6b. Rerank (optional)
742
+ let finalRanked = ranked;
743
+ if (rerankEnabled && ranked.length > 1) {
744
+ try {
745
+ const docs = ranked.map(r => buildRerankDocument(r, rerankMaxChars));
746
+ const rerankResult = await reranker.rerank(query, docs, { topN: ranked.length });
747
+ const scoreMap = new Map(rerankResult.map(r => [r.index, r.score]));
748
+
749
+ finalRanked = ranked.map((r, i) => ({
750
+ ...r,
751
+ _hybridScore: r._score,
752
+ _rerankScore: scoreMap.has(i) ? scoreMap.get(i) : null,
753
+ }));
754
+
755
+ finalRanked.sort((a, b) => {
756
+ const aR = a._rerankScore ?? -Infinity;
757
+ const bR = b._rerankScore ?? -Infinity;
758
+ if (aR !== bR) return bR - aR;
759
+ return (b._hybridScore || 0) - (a._hybridScore || 0);
760
+ });
761
+ finalRanked = finalRanked.slice(0, limit);
762
+ } catch (rerankErr) {
763
+ // Fallback: use original hybrid-rank order, flag in debug
764
+ if (process.env.AQUIFER_DEBUG) console.error('[aquifer] rerank error:', rerankErr.message);
765
+ finalRanked = ranked.slice(0, limit).map(r => ({ ...r, _rerankFallback: true }));
766
+ }
767
+ } else {
768
+ finalRanked = ranked.slice(0, limit);
769
+ }
770
+
696
771
  // 7. Record access
697
- const sessionRowIds = ranked
772
+ const sessionRowIds = finalRanked
698
773
  .map(r => r.id || r.session_row_id)
699
774
  .filter(Boolean);
700
775
 
@@ -705,7 +780,7 @@ function createAquifer(config) {
705
780
  }
706
781
 
707
782
  // 8. Format results
708
- return ranked.map(r => ({
783
+ return finalRanked.map(r => ({
709
784
  sessionId: r.session_id,
710
785
  agentId: r.agent_id,
711
786
  source: r.source,
@@ -715,7 +790,7 @@ function createAquifer(config) {
715
790
  summarySnippet: r.summary_snippet || null,
716
791
  matchedTurnText: r.matched_turn_text || null,
717
792
  matchedTurnIndex: r.matched_turn_index || null,
718
- score: r._score,
793
+ score: r._rerankScore ?? r._score,
719
794
  trustScore: r._trustScore ?? 0.5,
720
795
  _debug: {
721
796
  rrf: r._rrf,
@@ -725,6 +800,9 @@ function createAquifer(config) {
725
800
  trustScore: r._trustScore,
726
801
  trustMultiplier: r._trustMultiplier,
727
802
  openLoopBoost: r._openLoopBoost,
803
+ hybridScore: r._hybridScore ?? r._score,
804
+ rerankScore: r._rerankScore ?? null,
805
+ rerankFallback: r._rerankFallback || false,
728
806
  },
729
807
  }));
730
808
  },
package/core/entity.js CHANGED
@@ -141,21 +141,23 @@ async function upsertEntity(pool, {
141
141
  type = 'other',
142
142
  status = 'active',
143
143
  agentId = 'main',
144
+ entityScope,
144
145
  createdBy,
145
146
  metadata = {},
146
147
  embedding,
147
148
  occurredAt,
148
149
  }) {
150
+ const scope = entityScope || agentId || 'default';
149
151
  const normalizedAliases = aliases.map(a => normalizeEntityName(a)).filter(Boolean);
150
152
  const embStr = embedding ? vecToStr(embedding) : null;
151
153
  const ts = occurredAt || new Date().toISOString();
152
154
 
153
155
  const result = await pool.query(
154
156
  `INSERT INTO ${qi(schema)}.entities
155
- (tenant_id, name, normalized_name, aliases, type, status, agent_id,
157
+ (tenant_id, name, normalized_name, aliases, type, status, agent_id, entity_scope,
156
158
  created_by, metadata, embedding, first_seen_at, last_seen_at, frequency)
157
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10::vector, $11, $11, 1)
158
- ON CONFLICT (tenant_id, normalized_name, agent_id) DO UPDATE SET
159
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::jsonb, $11::vector, $12, $12, 1)
160
+ ON CONFLICT (tenant_id, normalized_name, entity_scope) DO UPDATE SET
159
161
  frequency = ${qi(schema)}.entities.frequency + 1,
160
162
  aliases = ARRAY(SELECT DISTINCT unnest(${qi(schema)}.entities.aliases || EXCLUDED.aliases)),
161
163
  last_seen_at = GREATEST(${qi(schema)}.entities.last_seen_at, EXCLUDED.last_seen_at),
@@ -164,7 +166,7 @@ async function upsertEntity(pool, {
164
166
  RETURNING id, (xmax = 0) AS is_new`,
165
167
  [
166
168
  tenantId, name, normalizedName, normalizedAliases,
167
- type, status, agentId,
169
+ type, status, agentId, scope,
168
170
  createdBy || null,
169
171
  JSON.stringify(metadata),
170
172
  embStr,
@@ -276,6 +278,7 @@ async function searchEntities(pool, {
276
278
  tenantId,
277
279
  query,
278
280
  agentId,
281
+ entityScope,
279
282
  limit = 10,
280
283
  similarityThreshold = 0.1,
281
284
  }) {
@@ -284,11 +287,13 @@ async function searchEntities(pool, {
284
287
  if (!normQ) return [];
285
288
 
286
289
  const escaped = _escapeIlike(normQ);
290
+ // Use entityScope if provided, fall back to agentId for backward compat
291
+ const scopeFilter = entityScope || agentId || null;
287
292
 
288
293
  const result = await pool.query(
289
294
  `SELECT
290
295
  id, name, normalized_name, aliases, type, status, frequency, agent_id,
291
- last_seen_at, metadata,
296
+ entity_scope, last_seen_at, metadata,
292
297
  similarity(normalized_name, $1) AS name_sim
293
298
  FROM ${qi(schema)}.entities
294
299
  WHERE status = 'active'
@@ -298,10 +303,10 @@ async function searchEntities(pool, {
298
303
  OR normalized_name ILIKE '%' || $4 || '%' ESCAPE '\\'
299
304
  OR $5 = ANY(aliases)
300
305
  )
301
- AND ($6::text IS NULL OR agent_id = $6)
306
+ AND ($6::text IS NULL OR entity_scope = $6)
302
307
  ORDER BY name_sim DESC, frequency DESC
303
308
  LIMIT $7`,
304
- [normQ, tenantId, similarityThreshold, escaped, normQ, agentId || null, clampedLimit]
309
+ [normQ, tenantId, similarityThreshold, escaped, normQ, scopeFilter, clampedLimit]
305
310
  );
306
311
 
307
312
  return result.rows;
@@ -353,9 +358,12 @@ async function resolveEntities(pool, {
353
358
  tenantId,
354
359
  names,
355
360
  agentId = null,
361
+ entityScope,
356
362
  threshold = 0.1,
357
363
  }) {
358
364
  if (!names || names.length === 0) return [];
365
+ // Use entityScope if provided, fall back to agentId for backward compat
366
+ const scopeFilter = entityScope || agentId || null;
359
367
 
360
368
  const seen = new Map();
361
369
  const results = [];
@@ -376,10 +384,10 @@ async function resolveEntities(pool, {
376
384
  OR normalized_name = $2
377
385
  OR $2 = ANY(aliases)
378
386
  )
379
- AND ($4::text IS NULL OR agent_id = $4)
387
+ AND ($4::text IS NULL OR entity_scope = $4)
380
388
  ORDER BY similarity(normalized_name, $2) DESC, frequency DESC
381
389
  LIMIT 1`,
382
- [tenantId, normQ, threshold, agentId || null]
390
+ [tenantId, normQ, threshold, scopeFilter]
383
391
  );
384
392
 
385
393
  if (result.rows[0]) {
package/core/storage.js CHANGED
@@ -331,12 +331,46 @@ async function searchSessions(pool, query, {
331
331
  schema,
332
332
  tenantId,
333
333
  agentId,
334
+ agentIds: rawAgentIds,
334
335
  source,
335
336
  dateFrom, // m1: add date filtering
336
337
  dateTo,
337
338
  limit = 20,
339
+ ftsConfig = 'simple',
338
340
  } = {}) {
339
341
  const clampedLimit = Math.max(1, Math.min(100, limit));
342
+ // Sanitize ftsConfig to prevent SQL injection (must be a valid regconfig name)
343
+ const safeFts = /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(ftsConfig) ? ftsConfig : 'simple';
344
+
345
+ // Normalize agentId/agentIds
346
+ const agentIds = rawAgentIds && rawAgentIds.length > 0
347
+ ? rawAgentIds
348
+ : (agentId ? [agentId] : null);
349
+
350
+ const where = [
351
+ `ss.search_tsv @@ plainto_tsquery('${safeFts}', $1)`,
352
+ `s.tenant_id = $2`,
353
+ ];
354
+ const params = [query, tenantId];
355
+
356
+ if (agentIds) {
357
+ params.push(agentIds);
358
+ where.push(`s.agent_id = ANY($${params.length})`);
359
+ }
360
+ if (source) {
361
+ params.push(source);
362
+ where.push(`s.source = $${params.length}`);
363
+ }
364
+ if (dateFrom) {
365
+ params.push(dateFrom);
366
+ where.push(`s.started_at::date >= $${params.length}::date`);
367
+ }
368
+ if (dateTo) {
369
+ params.push(dateTo);
370
+ where.push(`s.started_at::date <= $${params.length}::date`);
371
+ }
372
+ params.push(clampedLimit);
373
+
340
374
  const result = await pool.query(
341
375
  `SELECT
342
376
  s.id,
@@ -351,19 +385,14 @@ async function searchSessions(pool, query, {
351
385
  ss.access_count,
352
386
  ss.last_accessed_at,
353
387
  ss.trust_score,
354
- ts_headline('simple', COALESCE(ss.summary_text, ''), plainto_tsquery('simple', $1)) AS summary_snippet,
355
- ts_rank(ss.search_tsv, plainto_tsquery('simple', $1)) AS fts_rank
388
+ ts_headline('${safeFts}', COALESCE(ss.summary_text, ''), plainto_tsquery('${safeFts}', $1)) AS summary_snippet,
389
+ ts_rank(ss.search_tsv, plainto_tsquery('${safeFts}', $1)) AS fts_rank
356
390
  FROM ${qi(schema)}.sessions s
357
391
  LEFT JOIN ${qi(schema)}.session_summaries ss ON ss.session_row_id = s.id
358
- WHERE ss.search_tsv @@ plainto_tsquery('simple', $1)
359
- AND s.tenant_id = $2
360
- AND ($3::text IS NULL OR s.agent_id = $3)
361
- AND ($4::text IS NULL OR s.source = $4)
362
- AND ($5::date IS NULL OR s.started_at::date >= $5::date)
363
- AND ($6::date IS NULL OR s.started_at::date <= $6::date)
392
+ WHERE ${where.join(' AND ')}
364
393
  ORDER BY fts_rank DESC, s.last_message_at DESC NULLS LAST
365
- LIMIT $7`,
366
- [query, tenantId, agentId || null, source || null, dateFrom || null, dateTo || null, clampedLimit]
394
+ LIMIT $${params.length}`,
395
+ params
367
396
  );
368
397
  return result.rows;
369
398
  }
@@ -479,23 +508,29 @@ async function searchTurnEmbeddings(pool, {
479
508
  dateFrom,
480
509
  dateTo,
481
510
  agentId,
511
+ agentIds: rawAgentIds,
482
512
  source,
483
513
  limit = 15,
484
514
  }) {
485
515
  const where = ['s.tenant_id = $1'];
486
516
  const params = [tenantId];
487
517
 
518
+ // Normalize agentId/agentIds
519
+ const agentIds = rawAgentIds && rawAgentIds.length > 0
520
+ ? rawAgentIds
521
+ : (agentId ? [agentId] : null);
522
+
488
523
  if (dateFrom) {
489
524
  params.push(dateFrom);
490
- where.push(`($${params.length}::date IS NULL OR s.started_at::date >= $${params.length}::date)`);
525
+ where.push(`s.started_at::date >= $${params.length}::date`);
491
526
  }
492
527
  if (dateTo) {
493
528
  params.push(dateTo);
494
- where.push(`($${params.length}::date IS NULL OR s.started_at::date <= $${params.length}::date)`);
529
+ where.push(`s.started_at::date <= $${params.length}::date`);
495
530
  }
496
- if (agentId) {
497
- params.push(agentId);
498
- where.push(`t.agent_id = $${params.length}`);
531
+ if (agentIds) {
532
+ params.push(agentIds);
533
+ where.push(`t.agent_id = ANY($${params.length})`);
499
534
  }
500
535
  if (source) {
501
536
  params.push(source);
package/index.js CHANGED
@@ -2,5 +2,6 @@
2
2
 
3
3
  const { createAquifer } = require('./core/aquifer');
4
4
  const { createEmbedder } = require('./pipeline/embed');
5
+ const { createReranker } = require('./pipeline/rerank');
5
6
 
6
- module.exports = { createAquifer, createEmbedder };
7
+ module.exports = { createAquifer, createEmbedder, createReranker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. Includes CLI, MCP server, and OpenClaw plugin.",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ const http = require('http');
4
+ const https = require('https');
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // HTTP helpers (shared by embed.js and rerank.js)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ function httpRequest(url, options, body) {
11
+ return new Promise((resolve, reject) => {
12
+ const parsedUrl = new URL(url);
13
+ const transport = parsedUrl.protocol === 'https:' ? https : http;
14
+
15
+ // M8 fix: settled flag to prevent double-settle on timeout race
16
+ let settled = false;
17
+ const finish = (fn, val) => { if (!settled) { settled = true; fn(val); } };
18
+
19
+ const req = transport.request(parsedUrl, options, (res) => {
20
+ const chunks = [];
21
+ res.on('data', (chunk) => chunks.push(chunk));
22
+ res.on('end', () => {
23
+ if (timer) clearTimeout(timer);
24
+ const raw = Buffer.concat(chunks).toString();
25
+ if (res.statusCode < 200 || res.statusCode >= 300) {
26
+ finish(reject, new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`));
27
+ return;
28
+ }
29
+ try {
30
+ finish(resolve, JSON.parse(raw));
31
+ } catch (e) {
32
+ finish(reject, new Error(`Invalid JSON response: ${raw.slice(0, 200)}`));
33
+ }
34
+ });
35
+ });
36
+
37
+ const timer = options.timeout
38
+ ? setTimeout(() => { req.destroy(); finish(reject, new Error('Request timeout')); }, options.timeout)
39
+ : null;
40
+
41
+ req.on('error', (err) => { if (timer) clearTimeout(timer); finish(reject, err); });
42
+ if (body) req.write(JSON.stringify(body));
43
+ req.end();
44
+ });
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Retry wrapper
49
+ // ---------------------------------------------------------------------------
50
+
51
+ async function withRetry(fn, { maxRetries = 3, initialBackoffMs = 2000 } = {}) {
52
+ let lastErr;
53
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
54
+ try {
55
+ return await fn();
56
+ } catch (err) {
57
+ lastErr = err;
58
+ if (attempt < maxRetries - 1) {
59
+ const delay = initialBackoffMs * Math.pow(2, attempt);
60
+ await new Promise(r => setTimeout(r, delay));
61
+ }
62
+ }
63
+ }
64
+ throw lastErr;
65
+ }
66
+
67
+ module.exports = { httpRequest, withRetry };
package/pipeline/embed.js CHANGED
@@ -1,68 +1,6 @@
1
1
  'use strict';
2
2
 
3
- const http = require('http');
4
- const https = require('https');
5
-
6
- // ---------------------------------------------------------------------------
7
- // HTTP helpers
8
- // ---------------------------------------------------------------------------
9
-
10
- function httpRequest(url, options, body) {
11
- return new Promise((resolve, reject) => {
12
- const parsedUrl = new URL(url);
13
- const transport = parsedUrl.protocol === 'https:' ? https : http;
14
-
15
- // M8 fix: settled flag to prevent double-settle on timeout race
16
- let settled = false;
17
- const finish = (fn, val) => { if (!settled) { settled = true; fn(val); } };
18
-
19
- const req = transport.request(parsedUrl, options, (res) => {
20
- const chunks = [];
21
- res.on('data', (chunk) => chunks.push(chunk));
22
- res.on('end', () => {
23
- if (timer) clearTimeout(timer);
24
- const raw = Buffer.concat(chunks).toString();
25
- if (res.statusCode < 200 || res.statusCode >= 300) {
26
- finish(reject, new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 500)}`));
27
- return;
28
- }
29
- try {
30
- finish(resolve, JSON.parse(raw));
31
- } catch (e) {
32
- finish(reject, new Error(`Invalid JSON response: ${raw.slice(0, 200)}`));
33
- }
34
- });
35
- });
36
-
37
- const timer = options.timeout
38
- ? setTimeout(() => { req.destroy(); finish(reject, new Error('Request timeout')); }, options.timeout)
39
- : null;
40
-
41
- req.on('error', (err) => { if (timer) clearTimeout(timer); finish(reject, err); });
42
- if (body) req.write(JSON.stringify(body));
43
- req.end();
44
- });
45
- }
46
-
47
- // ---------------------------------------------------------------------------
48
- // Retry wrapper
49
- // ---------------------------------------------------------------------------
50
-
51
- async function withRetry(fn, { maxRetries = 3, initialBackoffMs = 2000 } = {}) {
52
- let lastErr;
53
- for (let attempt = 0; attempt < maxRetries; attempt++) {
54
- try {
55
- return await fn();
56
- } catch (err) {
57
- lastErr = err;
58
- if (attempt < maxRetries - 1) {
59
- const delay = initialBackoffMs * Math.pow(2, attempt);
60
- await new Promise(r => setTimeout(r, delay));
61
- }
62
- }
63
- }
64
- throw lastErr;
65
- }
3
+ const { httpRequest, withRetry } = require('./_http');
66
4
 
67
5
  // ---------------------------------------------------------------------------
68
6
  // Ollama adapter
@@ -0,0 +1,161 @@
1
+ 'use strict';
2
+
3
+ const { httpRequest, withRetry } = require('./_http');
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // Custom adapter
7
+ // ---------------------------------------------------------------------------
8
+
9
+ function validateResults(results) {
10
+ return results.filter(r =>
11
+ r && typeof r.index === 'number' && Number.isFinite(r.index)
12
+ && typeof r.score === 'number' && Number.isFinite(r.score)
13
+ );
14
+ }
15
+
16
+ function createCustomReranker(config) {
17
+ const fn = config.fn;
18
+ if (!fn) throw new Error('fn is required for custom reranker');
19
+
20
+ return {
21
+ async rerank(query, documents, opts = {}) {
22
+ if (!query || !documents || documents.length === 0) return [];
23
+ const topN = opts.topN || documents.length;
24
+ const results = await fn({ query, documents, topN });
25
+ if (!Array.isArray(results)) throw new Error('Custom reranker fn must return an array');
26
+ return validateResults(results).sort((a, b) => b.score - a.score);
27
+ },
28
+ };
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // TEI adapter (HuggingFace Text Embeddings Inference)
33
+ // ---------------------------------------------------------------------------
34
+
35
+ function createTEIReranker(config) {
36
+ const baseUrl = (config.teiBaseUrl || config.baseUrl || 'http://localhost:8080').replace(/\/+$/, '');
37
+ const timeout = config.timeout || 2000;
38
+ const maxRetries = config.maxRetries ?? 1;
39
+ const initialBackoffMs = config.initialBackoffMs || 250;
40
+
41
+ return {
42
+ async rerank(query, documents, opts = {}) {
43
+ if (!query || !documents || documents.length === 0) return [];
44
+
45
+ const result = await withRetry(
46
+ () => httpRequest(`${baseUrl}/rerank`, {
47
+ method: 'POST',
48
+ headers: { 'Content-Type': 'application/json' },
49
+ timeout,
50
+ }, { query, texts: documents, raw_scores: false }),
51
+ { maxRetries, initialBackoffMs },
52
+ );
53
+
54
+ // TEI returns array of { index, score }
55
+ const arr = Array.isArray(result) ? result : [];
56
+ return validateResults(arr.map(r => ({ index: r.index, score: r.score })))
57
+ .sort((a, b) => b.score - a.score);
58
+ },
59
+ };
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Jina adapter
64
+ // ---------------------------------------------------------------------------
65
+
66
+ function createJinaReranker(config) {
67
+ const apiKey = config.jinaApiKey;
68
+ if (!apiKey) throw new Error('jinaApiKey is required for Jina reranker');
69
+
70
+ const model = config.jinaModel || 'jina-reranker-v2-base-multilingual';
71
+ const baseUrl = (config.jinaBaseUrl || 'https://api.jina.ai/v1/rerank').replace(/\/+$/, '');
72
+ const timeout = config.timeout || 2000;
73
+ const maxRetries = config.maxRetries ?? 1;
74
+ const initialBackoffMs = config.initialBackoffMs || 250;
75
+
76
+ return {
77
+ async rerank(query, documents, opts = {}) {
78
+ if (!query || !documents || documents.length === 0) return [];
79
+ const topN = opts.topN || documents.length;
80
+
81
+ const result = await withRetry(
82
+ () => httpRequest(baseUrl, {
83
+ method: 'POST',
84
+ headers: {
85
+ 'Content-Type': 'application/json',
86
+ 'Authorization': `Bearer ${apiKey}`,
87
+ },
88
+ timeout,
89
+ }, { model, query, documents, top_n: topN }),
90
+ { maxRetries, initialBackoffMs },
91
+ );
92
+
93
+ // Jina returns { results: [{ index, relevance_score }] }
94
+ const arr = result.results || [];
95
+ return validateResults(arr.map(r => ({ index: r.index, score: r.relevance_score })))
96
+ .sort((a, b) => b.score - a.score);
97
+ },
98
+ };
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // OpenRouter adapter (Cohere rerank etc. via OpenRouter)
103
+ // ---------------------------------------------------------------------------
104
+
105
+ function createOpenRouterReranker(config) {
106
+ const apiKey = config.openrouterApiKey || config.apiKey;
107
+ if (!apiKey) throw new Error('openrouterApiKey is required for OpenRouter reranker');
108
+
109
+ const model = config.model || 'cohere/rerank-v3.5';
110
+ const baseUrl = (config.openrouterBaseUrl || 'https://openrouter.ai/api/v1/rerank').replace(/\/+$/, '');
111
+ const timeout = config.timeout || 5000;
112
+ const maxRetries = config.maxRetries ?? 1;
113
+ const initialBackoffMs = config.initialBackoffMs || 250;
114
+
115
+ return {
116
+ async rerank(query, documents, opts = {}) {
117
+ if (!query || !documents || documents.length === 0) return [];
118
+ const topN = opts.topN || documents.length;
119
+
120
+ const result = await withRetry(
121
+ () => httpRequest(baseUrl, {
122
+ method: 'POST',
123
+ headers: {
124
+ 'Content-Type': 'application/json',
125
+ 'Authorization': `Bearer ${apiKey}`,
126
+ },
127
+ timeout,
128
+ }, { model, query, documents, top_n: topN }),
129
+ { maxRetries, initialBackoffMs },
130
+ );
131
+
132
+ // OpenRouter returns { results: [{ index, relevance_score }] }
133
+ const arr = result.results || [];
134
+ return validateResults(arr.map(r => ({ index: r.index, score: r.relevance_score })))
135
+ .sort((a, b) => b.score - a.score);
136
+ },
137
+ };
138
+ }
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // Factory
142
+ // ---------------------------------------------------------------------------
143
+
144
+ function createReranker(config = {}) {
145
+ const provider = config.provider || 'custom';
146
+
147
+ switch (provider) {
148
+ case 'custom':
149
+ return createCustomReranker(config);
150
+ case 'tei':
151
+ return createTEIReranker(config);
152
+ case 'jina':
153
+ return createJinaReranker(config);
154
+ case 'openrouter':
155
+ return createOpenRouterReranker(config);
156
+ default:
157
+ throw new Error(`Unknown rerank provider: ${provider}`);
158
+ }
159
+ }
160
+
161
+ module.exports = { createReranker };
@@ -24,10 +24,11 @@ CREATE TABLE IF NOT EXISTS ${schema}.sessions (
24
24
  started_at TIMESTAMPTZ,
25
25
  ended_at TIMESTAMPTZ,
26
26
  last_message_at TIMESTAMPTZ,
27
- processing_status TEXT NOT NULL DEFAULT 'pending',
28
- processed_at TIMESTAMPTZ,
29
- processing_error TEXT,
30
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
+ processing_status TEXT NOT NULL DEFAULT 'pending',
28
+ processing_started_at TIMESTAMPTZ,
29
+ processed_at TIMESTAMPTZ,
30
+ processing_error TEXT,
31
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
31
32
  UNIQUE (tenant_id, agent_id, session_id)
32
33
  );
33
34
 
@@ -20,16 +20,28 @@ CREATE TABLE IF NOT EXISTS ${schema}.entities (
20
20
  CHECK (status IN ('active','merged','deleted')),
21
21
  frequency INT NOT NULL DEFAULT 1,
22
22
  agent_id TEXT NOT NULL DEFAULT 'main',
23
+ entity_scope TEXT NOT NULL DEFAULT 'default',
23
24
  created_by TEXT,
24
25
  metadata JSONB NOT NULL DEFAULT '{}',
25
26
  embedding vector,
26
27
  first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
27
- last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
28
- UNIQUE (tenant_id, normalized_name, agent_id)
28
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now()
29
29
  );
30
30
 
31
- CREATE INDEX IF NOT EXISTS idx_entities_tenant_agent
32
- ON ${schema}.entities (tenant_id, agent_id);
31
+ -- Migration: add entity_scope if missing (idempotent)
32
+ -- For upgrades: backfill from agent_id so existing data keeps its scope
33
+ ALTER TABLE ${schema}.entities ADD COLUMN IF NOT EXISTS entity_scope TEXT DEFAULT 'default';
34
+ UPDATE ${schema}.entities SET entity_scope = agent_id WHERE entity_scope IS NULL OR entity_scope = 'default';
35
+ ALTER TABLE ${schema}.entities ALTER COLUMN entity_scope SET NOT NULL;
36
+
37
+ -- Unique constraint: entity identity is (tenant, name, scope)
38
+ -- Drop legacy agent-based constraint if it exists
39
+ DROP INDEX IF EXISTS ${schema}.idx_entities_tenant_name_agent;
40
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_tenant_name_scope
41
+ ON ${schema}.entities (tenant_id, normalized_name, entity_scope);
42
+
43
+ CREATE INDEX IF NOT EXISTS idx_entities_tenant_scope
44
+ ON ${schema}.entities (tenant_id, entity_scope);
33
45
 
34
46
  CREATE INDEX IF NOT EXISTS idx_entities_type
35
47
  ON ${schema}.entities (type);
@@ -44,7 +56,7 @@ CREATE INDEX IF NOT EXISTS idx_entities_aliases
44
56
  ON ${schema}.entities USING GIN (aliases);
45
57
 
46
58
  CREATE INDEX IF NOT EXISTS idx_entities_active
47
- ON ${schema}.entities (tenant_id, agent_id, frequency DESC)
59
+ ON ${schema}.entities (tenant_id, entity_scope, frequency DESC)
48
60
  WHERE status = 'active';
49
61
 
50
62
  -- =========================================================================