@shadowforge0/aquifer-memory 0.4.1 → 0.6.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 +132 -81
- package/consumers/cli.js +9 -4
- package/consumers/mcp.js +1 -1
- package/core/aquifer.js +58 -5
- package/core/entity.js +17 -9
- package/package.json +1 -1
- package/schema/001-base.sql +5 -4
- package/schema/002-entities.sql +17 -5
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
|
[](https://www.npmjs.com/package/@shadowforge0/aquifer-memory)
|
|
10
10
|
[](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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
###
|
|
91
|
+
### Write path: commit + enrich
|
|
92
92
|
|
|
93
93
|
```javascript
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
###
|
|
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
|
|
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.
|
|
292
|
+
#### `aquifer.commit(sessionId, messages, opts)`
|
|
267
293
|
|
|
268
|
-
|
|
294
|
+
Stores a session. Returns `{ id, sessionId, isNew }`.
|
|
269
295
|
|
|
270
296
|
```javascript
|
|
271
|
-
await aquifer.
|
|
272
|
-
sessionId: 'unique-id',
|
|
297
|
+
await aquifer.commit('session-001', messages, {
|
|
273
298
|
agentId: 'main',
|
|
274
|
-
source: 'api',
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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.
|
|
309
|
+
#### `aquifer.enrich(sessionId, opts)`
|
|
284
310
|
|
|
285
|
-
|
|
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,
|
|
291
|
-
entities: ['postgres', 'migration'],
|
|
336
|
+
limit: 10,
|
|
337
|
+
entities: ['postgres', 'migration'],
|
|
292
338
|
entityMode: 'all', // 'any' (default) or 'all'
|
|
293
|
-
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,
|
|
344
|
+
#### `aquifer.feedback(sessionId, opts)`
|
|
305
345
|
|
|
306
|
-
Records
|
|
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',
|
|
312
|
-
note: 'reason',
|
|
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
|
-
//
|
|
332
|
-
schema: 'aquifer',
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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,
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
package/core/aquifer.js
CHANGED
|
@@ -79,6 +79,7 @@ function createAquifer(config) {
|
|
|
79
79
|
let entitiesEnabled = config.entities && config.entities.enabled === true;
|
|
80
80
|
const mergeCall = config.entities && config.entities.mergeCall !== undefined ? config.entities.mergeCall : true;
|
|
81
81
|
const entityPromptFn = config.entities && config.entities.prompt ? config.entities.prompt : null;
|
|
82
|
+
const entityScope = (config.entities && config.entities.scope) || 'default';
|
|
82
83
|
|
|
83
84
|
// Rank weights
|
|
84
85
|
const rankWeights = {
|
|
@@ -259,12 +260,19 @@ function createAquifer(config) {
|
|
|
259
260
|
const customSummaryFn = opts.summaryFn || null; // async (messages) => { summaryText, structuredSummary, entityRaw?, extra? }
|
|
260
261
|
const customEntityParseFn = opts.entityParseFn || null; // (text) => [{ name, normalizedName, aliases, type }]
|
|
261
262
|
|
|
263
|
+
// Post-commit hook: runs after tx commit + client release. Best-effort, at-most-once.
|
|
264
|
+
const postProcess = opts.postProcess || null; // async (ctx) => void
|
|
265
|
+
const optModel = 'model' in opts ? opts.model : undefined; // undefined = no override
|
|
266
|
+
|
|
262
267
|
// 1. Optimistic lock: claim session for processing
|
|
268
|
+
// Also reclaim stale 'processing' sessions (stuck > 10 min = likely killed process)
|
|
269
|
+
const STALE_MINUTES = 10;
|
|
263
270
|
const claimResult = await pool.query(
|
|
264
271
|
`UPDATE ${qi(schema)}.sessions
|
|
265
|
-
SET processing_status = 'processing'
|
|
272
|
+
SET processing_status = 'processing', processing_started_at = NOW()
|
|
266
273
|
WHERE session_id = $1 AND agent_id = $2 AND tenant_id = $3
|
|
267
|
-
AND processing_status IN ('pending', 'failed')
|
|
274
|
+
AND (processing_status IN ('pending', 'failed')
|
|
275
|
+
OR (processing_status = 'processing' AND (processing_started_at IS NULL OR processing_started_at < NOW() - INTERVAL '${STALE_MINUTES} minutes')))
|
|
268
276
|
RETURNING *`,
|
|
269
277
|
[sessionId, agentId, tenantId]
|
|
270
278
|
);
|
|
@@ -361,7 +369,7 @@ function createAquifer(config) {
|
|
|
361
369
|
schema, tenantId, agentId, sessionId,
|
|
362
370
|
summaryText: summaryResult.summaryText,
|
|
363
371
|
structuredSummary: summaryResult.structuredSummary,
|
|
364
|
-
model: session.model || null, sourceHash: null,
|
|
372
|
+
model: (optModel !== undefined ? optModel : session.model) || null, sourceHash: null,
|
|
365
373
|
msgCount: normalized.length,
|
|
366
374
|
userCount: turns.length,
|
|
367
375
|
assistantCount: normalized.filter(m => m.role === 'assistant').length,
|
|
@@ -394,6 +402,7 @@ function createAquifer(config) {
|
|
|
394
402
|
aliases: ent.aliases,
|
|
395
403
|
type: ent.type,
|
|
396
404
|
agentId,
|
|
405
|
+
entityScope,
|
|
397
406
|
createdBy: 'aquifer',
|
|
398
407
|
occurredAt: session.started_at ? new Date(session.started_at).toISOString() : null,
|
|
399
408
|
});
|
|
@@ -453,6 +462,41 @@ function createAquifer(config) {
|
|
|
453
462
|
client.release();
|
|
454
463
|
}
|
|
455
464
|
|
|
465
|
+
// Post-commit hook: best-effort, at-most-once, no retry.
|
|
466
|
+
// Runs after tx commit + client release. Failure does not affect session status.
|
|
467
|
+
const effectiveModel = (optModel !== undefined ? optModel : session.model) || null;
|
|
468
|
+
let postProcessError = null;
|
|
469
|
+
if (postProcess) {
|
|
470
|
+
try {
|
|
471
|
+
await postProcess({
|
|
472
|
+
session: {
|
|
473
|
+
id: session.id,
|
|
474
|
+
sessionId,
|
|
475
|
+
agentId,
|
|
476
|
+
model: session.model || null,
|
|
477
|
+
source: session.source || null,
|
|
478
|
+
startedAt: session.started_at || null,
|
|
479
|
+
endedAt: session.ended_at || null,
|
|
480
|
+
},
|
|
481
|
+
effectiveModel,
|
|
482
|
+
summary: summaryResult
|
|
483
|
+
? { summaryText: summaryResult.summaryText, structuredSummary: summaryResult.structuredSummary }
|
|
484
|
+
: null,
|
|
485
|
+
embedding: summaryEmbedding,
|
|
486
|
+
turnVectors,
|
|
487
|
+
extra,
|
|
488
|
+
normalized,
|
|
489
|
+
parsedEntities,
|
|
490
|
+
skipped: { summary: skipSummary, entities: skipEntities, turns: skipTurnEmbed },
|
|
491
|
+
turnsEmbedded,
|
|
492
|
+
entitiesFound,
|
|
493
|
+
warnings: [...warnings], // defensive copy — caller cannot mutate enrich warnings
|
|
494
|
+
});
|
|
495
|
+
} catch (e) {
|
|
496
|
+
postProcessError = e;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
456
500
|
return {
|
|
457
501
|
summary: summaryResult ? summaryResult.summaryText : null,
|
|
458
502
|
structuredSummary: summaryResult ? summaryResult.structuredSummary : null,
|
|
@@ -460,6 +504,15 @@ function createAquifer(config) {
|
|
|
460
504
|
entitiesFound,
|
|
461
505
|
warnings,
|
|
462
506
|
extra,
|
|
507
|
+
session: {
|
|
508
|
+
id: session.id,
|
|
509
|
+
sessionId,
|
|
510
|
+
agentId,
|
|
511
|
+
model: session.model || null,
|
|
512
|
+
source: session.source || null,
|
|
513
|
+
},
|
|
514
|
+
effectiveModel,
|
|
515
|
+
postProcessError,
|
|
463
516
|
};
|
|
464
517
|
},
|
|
465
518
|
|
|
@@ -501,7 +554,7 @@ function createAquifer(config) {
|
|
|
501
554
|
if (explicitEntities && explicitEntities.length > 0) {
|
|
502
555
|
|
|
503
556
|
const resolved = await entity.resolveEntities(pool, {
|
|
504
|
-
schema, tenantId, names: explicitEntities,
|
|
557
|
+
schema, tenantId, names: explicitEntities, entityScope,
|
|
505
558
|
});
|
|
506
559
|
|
|
507
560
|
if (resolved.length === 0) return [];
|
|
@@ -546,7 +599,7 @@ function createAquifer(config) {
|
|
|
546
599
|
// No explicit entities: existing query-text-based entity boost
|
|
547
600
|
try {
|
|
548
601
|
const matchedEntities = await entity.searchEntities(pool, {
|
|
549
|
-
schema, tenantId, query,
|
|
602
|
+
schema, tenantId, query, entityScope, limit: 10,
|
|
550
603
|
});
|
|
551
604
|
|
|
552
605
|
if (matchedEntities.length > 0) {
|
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, $
|
|
158
|
-
ON CONFLICT (tenant_id, normalized_name,
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
390
|
+
[tenantId, normQ, threshold, scopeFilter]
|
|
383
391
|
);
|
|
384
392
|
|
|
385
393
|
if (result.rows[0]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shadowforge0/aquifer-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.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": [
|
package/schema/001-base.sql
CHANGED
|
@@ -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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
package/schema/002-entities.sql
CHANGED
|
@@ -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
|
-
|
|
32
|
-
|
|
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,
|
|
59
|
+
ON ${schema}.entities (tenant_id, entity_scope, frequency DESC)
|
|
48
60
|
WHERE status = 'active';
|
|
49
61
|
|
|
50
62
|
-- =========================================================================
|