@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 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(
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, agentId,
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, agentId, limit: 10,
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, $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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "0.4.1",
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": [
@@ -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
  -- =========================================================================