@shadowforge0/aquifer-memory 0.8.0 → 0.9.1

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
@@ -32,7 +32,7 @@ Sessions, summaries, turn-level embeddings, entity graph — all live in one dat
32
32
  | **Ranking** | 3-way RRF: FTS + session embedding + turn embedding | Single vector similarity |
33
33
  | **Knowledge graph** | Built-in entity extraction & co-occurrence | Usually separate system |
34
34
  | **Multi-tenant** | `tenant_id` on every table, day-1 | Often an afterthought |
35
- | **Dependencies** | Just `pg` | Multiple SDKs |
35
+ | **Dependencies** | `pg` + MCP SDK | Multiple SDKs |
36
36
 
37
37
  ### Before and after
38
38
 
@@ -48,80 +48,150 @@ Sessions, summaries, turn-level embeddings, entity graph — all live in one dat
48
48
 
49
49
  ---
50
50
 
51
- ## Quick Start
51
+ ## Requirements
52
52
 
53
- ### Prerequisites
53
+ | Component | Required? | Purpose | Example |
54
+ |-----------|-----------|---------|---------|
55
+ | Node.js >= 18 | Yes | Runtime | — |
56
+ | PostgreSQL 15+ | Yes | Storage for sessions, summaries, entities | Local, Docker, or managed |
57
+ | pgvector extension | Yes | Vector similarity search | `CREATE EXTENSION vector;` (included in `pgvector/pgvector` Docker image) |
58
+ | Embedding endpoint | Yes (for recall) | Turn + session embedding | Ollama `bge-m3`, OpenAI `text-embedding-3-small`, any OpenAI-compatible API |
59
+ | LLM endpoint | Optional | Built-in summarization during `enrich` | Ollama, OpenRouter, OpenAI — or provide your own `summaryFn` |
60
+ | `@modelcontextprotocol/sdk` + `zod` | Yes (for MCP server) | MCP protocol runtime | Included in dependencies — installed automatically |
54
61
 
55
- - Node.js >= 18
56
- - PostgreSQL 15+ with [pgvector](https://github.com/pgvector/pgvector) extension
57
- - An embedding API (OpenAI, Ollama, or any OpenAI-compatible endpoint)
62
+ ---
63
+
64
+ ## Quick Start (MCP Server)
65
+
66
+ This gets you from zero to a working MCP memory server. For library API usage, see [API Reference](#api-reference) below.
67
+
68
+ ### 1. Start the stack
69
+
70
+ ```bash
71
+ docker compose up -d
72
+ # Starts PostgreSQL 16 + pgvector and Ollama with bge-m3 (auto-pulled).
73
+ # First run takes a few minutes while Ollama downloads the model.
74
+ ```
75
+
76
+ Already have PostgreSQL + pgvector and an embedding endpoint? Skip this step.
58
77
 
59
- ### Install
78
+ ### 2. Install
60
79
 
61
80
  ```bash
62
81
  npm install @shadowforge0/aquifer-memory
63
82
  ```
64
83
 
65
- ### Initialize
84
+ ### 3. Configure + verify
66
85
 
67
- ```javascript
68
- const { createAquifer } = require('@shadowforge0/aquifer-memory');
86
+ ```bash
87
+ export DATABASE_URL="postgresql://aquifer:aquifer@localhost:5432/aquifer"
88
+ export AQUIFER_EMBED_BASE_URL="http://localhost:11434/v1"
89
+ export AQUIFER_EMBED_MODEL="bge-m3"
69
90
 
70
- const aquifer = createAquifer({
71
- db: 'postgresql://user:pass@localhost:5432/mydb', // connection string or pg.Pool
72
- schema: 'memory', // PG schema name (default: 'aquifer')
73
- tenantId: 'default', // multi-tenant isolation
74
- embed: {
75
- fn: async (texts) => embeddings, // your embedding function
76
- dim: 1024, // optional dimension hint
77
- },
78
- llm: {
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
- },
85
- });
91
+ npx aquifer quickstart
92
+ ```
86
93
 
87
- // Run migrations (safe to call multiple times)
88
- await aquifer.migrate();
94
+ `quickstart` runs migrations, commits a test session, embeds it, recalls it, and cleans up. If it prints `✓ Aquifer is working`, you're done.
95
+
96
+ ### 4. Start the MCP server
97
+
98
+ ```bash
99
+ npx aquifer mcp
89
100
  ```
90
101
 
91
- ### Write path: commit + enrich
102
+ See [.env.example](.env.example) for all env vars, or [docs/setup.md](docs/setup.md) for the full setup guide.
92
103
 
93
- ```javascript
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', {
102
- agentId: 'main',
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
- },
110
- });
104
+ ---
105
+
106
+ ## Environment Variables
107
+
108
+ | Variable | Required? | Purpose | Example |
109
+ |----------|-----------|---------|---------|
110
+ | `DATABASE_URL` | Yes | PostgreSQL connection string | `postgresql://user:pass@localhost:5432/mydb` |
111
+ | `AQUIFER_SCHEMA` | No | PG schema name (default: `aquifer`) | `memory` |
112
+ | `AQUIFER_TENANT_ID` | No | Multi-tenant key (default: `default`) | `my-app` |
113
+ | `AQUIFER_EMBED_BASE_URL` | Yes (for recall) | Embedding API base URL | `http://localhost:11434/v1` |
114
+ | `AQUIFER_EMBED_MODEL` | Yes (for recall) | Embedding model name | `bge-m3` |
115
+ | `AQUIFER_EMBED_API_KEY` | Provider-dependent | API key for hosted embedding providers | `sk-...` |
116
+ | `AQUIFER_EMBED_DIM` | No | Embedding dimension override (auto-detected) | `1024` |
117
+ | `AQUIFER_LLM_BASE_URL` | No | LLM API base URL (for built-in summarization) | `http://localhost:11434/v1` |
118
+ | `AQUIFER_LLM_MODEL` | No | LLM model name | `llama3.1` |
119
+ | `AQUIFER_LLM_API_KEY` | Provider-dependent | API key for hosted LLM providers | `sk-...` |
120
+ | `AQUIFER_ENTITIES_ENABLED` | No | Enable knowledge graph (default: `false`) | `true` |
121
+ | `AQUIFER_ENTITY_SCOPE` | No | Entity namespace (default: `default`) | `my-app` |
122
+ | `AQUIFER_RERANK_ENABLED` | No | Enable cross-encoder reranking | `true` |
123
+ | `AQUIFER_RERANK_PROVIDER` | No | Reranker provider: `tei`, `jina`, `openrouter` | `tei` |
124
+ | `AQUIFER_RERANK_BASE_URL` | No | Reranker endpoint | `http://localhost:8080` |
125
+ | `AQUIFER_AGENT_ID` | No | Default agent ID | `main` |
126
+
127
+ Full env-to-config mapping is in [consumers/shared/config.js](consumers/shared/config.js).
128
+
129
+ ---
130
+
131
+ ## Host Integration
132
+
133
+ MCP is the primary integration surface. Agent hosts connect to the Aquifer MCP server, which exposes four tools: `session_recall`, `session_feedback`, `memory_stats`, `memory_pending`.
134
+
135
+ | Integration | Route | Status | When to use |
136
+ |-------------|-------|--------|-------------|
137
+ | MCP server | `consumers/mcp.js` | Primary | Claude Code, OpenClaw, Codex, any MCP-capable host |
138
+ | Library API | `createAquifer()` | Primary | Backend apps, custom pipelines, direct Node.js usage |
139
+ | CLI | `consumers/cli.js` | Secondary | Operations, debugging, manual recall/backfill |
140
+ | OpenClaw plugin | `consumers/openclaw-plugin.js` | Compatibility only | Session capture via `before_reset` — not for tool delivery |
141
+
142
+ ### Claude Code
143
+
144
+ Add to your project's `.claude.json` or user-level MCP config:
145
+
146
+ ```json
147
+ {
148
+ "mcpServers": {
149
+ "aquifer": {
150
+ "type": "stdio",
151
+ "command": "node",
152
+ "args": ["/path/to/aquifer/consumers/mcp.js"],
153
+ "env": {
154
+ "DATABASE_URL": "postgresql://...",
155
+ "AQUIFER_EMBED_BASE_URL": "http://localhost:11434/v1",
156
+ "AQUIFER_EMBED_MODEL": "bge-m3"
157
+ }
158
+ }
159
+ }
160
+ }
111
161
  ```
112
162
 
113
- ### Read path: recall
163
+ Tools appear as `mcp__aquifer__session_recall`, `mcp__aquifer__session_feedback`, etc.
114
164
 
115
- ```javascript
116
- const results = await aquifer.recall('auth middleware decision', {
117
- agentId: 'main',
118
- limit: 5,
119
- entities: ['auth-middleware'], // optional: entity-aware search
120
- entityMode: 'all', // 'any' (boost) or 'all' (hard filter)
121
- });
122
- // Returns ranked sessions with scores, using 3-way RRF fusion
165
+ ### OpenClaw
166
+
167
+ Add to `openclaw.json` under `mcp.servers`:
168
+
169
+ ```json
170
+ {
171
+ "mcp": {
172
+ "servers": {
173
+ "aquifer": {
174
+ "command": "node",
175
+ "args": ["/path/to/aquifer/consumers/mcp.js"],
176
+ "env": {
177
+ "DATABASE_URL": "postgresql://...",
178
+ "AQUIFER_EMBED_BASE_URL": "http://localhost:11434/v1",
179
+ "AQUIFER_EMBED_MODEL": "bge-m3"
180
+ }
181
+ }
182
+ }
183
+ }
184
+ }
123
185
  ```
124
186
 
187
+ Tools materialize as `aquifer__session_recall`, `aquifer__session_feedback`, `aquifer__memory_stats`, `aquifer__memory_pending` (server name prefix added by the host).
188
+
189
+ The OpenClaw plugin (`consumers/openclaw-plugin.js`) is retained for session capture via `before_reset` but is **not** the recommended tool delivery path. Use MCP.
190
+
191
+ ### Other MCP-capable hosts
192
+
193
+ Any host that supports MCP stdio can connect the same way — point it at `node consumers/mcp.js` with the required env vars. The MCP server is the canonical external contract.
194
+
125
195
  ---
126
196
 
127
197
  ## Architecture
@@ -161,8 +231,6 @@ const results = await aquifer.recall('auth middleware decision', {
161
231
  └──────────────────────────────────┘
162
232
  ```
163
233
 
164
- **Integration model:** MCP is the primary integration surface. Agent hosts connect to Aquifer through the MCP server (`consumers/mcp.js`), which exposes `session_recall`, `session_feedback`, `memory_stats`, and `memory_pending`. The CLI wraps the same engine for command-line use. The OpenClaw plugin (`consumers/openclaw-plugin.js`) is retained as a compatibility adapter for session capture but is not the primary tool delivery path.
165
-
166
234
  ### File Reference
167
235
 
168
236
  | File | Purpose |
@@ -375,7 +443,9 @@ Closes the PostgreSQL connection pool (only if Aquifer created it).
375
443
 
376
444
  ## Configuration
377
445
 
378
- Aquifer takes a `db` connection (string or `pg.Pool`), plus optional `embed` and `llm` functions:
446
+ Aquifer resolves config from three sources in priority order: config file → environment variables → programmatic overrides. See [consumers/shared/config.js](consumers/shared/config.js) for the full env-to-config mapping.
447
+
448
+ Config file is auto-discovered at `aquifer.config.json` in the working directory, or set `AQUIFER_CONFIG=/path/to/config.json`.
379
449
 
380
450
  ```javascript
381
451
  createAquifer({
@@ -409,61 +479,6 @@ createAquifer({
409
479
 
410
480
  Fallback chain: `config.entities.scope` → `'default'`.
411
481
 
412
- ### MCP Server (primary integration)
413
-
414
- Agent hosts should connect through the Aquifer MCP server. For OpenClaw, add to `openclaw.json`:
415
-
416
- ```json
417
- {
418
- "mcp": {
419
- "servers": {
420
- "aquifer": {
421
- "command": "node",
422
- "args": ["/path/to/aquifer/consumers/mcp.js"],
423
- "env": {
424
- "DATABASE_URL": "postgresql://...",
425
- "AQUIFER_SCHEMA": "aquifer",
426
- "AQUIFER_EMBED_BASE_URL": "http://localhost:11434/v1",
427
- "AQUIFER_EMBED_MODEL": "bge-m3"
428
- }
429
- }
430
- }
431
- }
432
- }
433
- ```
434
-
435
- Tools are exposed as `aquifer__session_recall`, `aquifer__session_feedback`, `aquifer__memory_stats`, `aquifer__memory_pending` (server name prefix is added by the host).
436
-
437
- For Claude Code, add to `.claude.json`:
438
-
439
- ```json
440
- {
441
- "mcpServers": {
442
- "aquifer": {
443
- "type": "stdio",
444
- "command": "node",
445
- "args": ["/path/to/aquifer/consumers/mcp.js"]
446
- }
447
- }
448
- }
449
- ```
450
-
451
- ### CLI (secondary)
452
-
453
- For command-line use with environment variables:
454
-
455
- ```bash
456
- export DATABASE_URL="postgresql://..."
457
- export AQUIFER_EMBED_BASE_URL="http://localhost:11434/v1"
458
- export AQUIFER_EMBED_MODEL="bge-m3"
459
- export AQUIFER_ENTITIES_ENABLED=true
460
-
461
- aquifer migrate
462
- aquifer recall "search query" --limit 5
463
- aquifer backfill --concurrency 3
464
- aquifer stats --json
465
- ```
466
-
467
482
  ---
468
483
 
469
484
  ## Database Schema
@@ -478,6 +493,8 @@ aquifer stats --json
478
493
 
479
494
  Key indexes: GIN on messages, GiST on `tsvector`, ivfflat on embeddings, B-tree on tenant/agent/timestamps.
480
495
 
496
+ Note: the schema uses basic ivfflat indexes suitable for development and moderate-scale use. For large deployments (100k+ embeddings), consider adding HNSW indexes — this is a future optimization area, not included out of the box.
497
+
481
498
  ### 002-entities.sql
482
499
 
483
500
  | Table | Purpose |
@@ -499,15 +516,29 @@ Also adds `trust_score` column to `session_summaries` (default 0.5, range 0–1)
499
516
 
500
517
  ---
501
518
 
519
+ ## Troubleshooting
520
+
521
+ **`error: type "vector" does not exist`** — pgvector extension is not installed. Run `CREATE EXTENSION IF NOT EXISTS vector;` as a superuser, or use the `pgvector/pgvector` Docker image which includes it.
522
+
523
+ **`aquifer mcp requires @modelcontextprotocol/sdk and zod`** — These are now regular dependencies and should be installed automatically. If you see this error, run `npm install` again to ensure all deps are present.
524
+
525
+ **Recall returns no results** — Make sure you've run `enrich` after `commit`. Raw sessions are not searchable until enriched (summarized + embedded). Check `aquifer stats` to see if summaries and turn embeddings exist.
526
+
527
+ **OpenClaw tools not visible** — Use `mcp.servers.aquifer` in `openclaw.json`, not the plugin. Tools appear as `aquifer__session_recall` etc. The plugin (`consumers/openclaw-plugin.js`) is for session capture only.
528
+
529
+ **Embedding provider connection refused** — Verify your `AQUIFER_EMBED_BASE_URL` is reachable. For local Ollama, make sure the server is running and the model is pulled (`ollama pull bge-m3`).
530
+
531
+ ---
532
+
502
533
  ## Dependencies
503
534
 
504
535
  | Package | Purpose |
505
536
  |---------|---------|
506
537
  | `pg` ≥ 8.13 | PostgreSQL client |
538
+ | `@modelcontextprotocol/sdk` ≥ 1.29 | MCP server protocol |
539
+ | `zod` ≥ 3.25 | Schema validation (MCP tools) |
507
540
 
508
- That's it. Aquifer has **one runtime dependency**.
509
-
510
- LLM and embedding calls use raw HTTP — no SDK required.
541
+ LLM and embedding calls use raw HTTP — no additional SDK required.
511
542
 
512
543
  ---
513
544
 
package/consumers/cli.js CHANGED
@@ -5,6 +5,7 @@
5
5
  * Aquifer CLI
6
6
  *
7
7
  * Usage:
8
+ * aquifer quickstart Verify end-to-end setup
8
9
  * aquifer migrate Run database migrations
9
10
  * aquifer recall <query> [options] Search sessions
10
11
  * aquifer backfill [options] Enrich pending sessions
@@ -15,6 +16,26 @@
15
16
 
16
17
  const { createAquiferFromConfig } = require('./shared/factory');
17
18
 
19
+ function formatDate(value, fallback) {
20
+ if (!value) return fallback;
21
+ const parsed = new Date(value);
22
+ return isNaN(parsed.getTime()) ? fallback : parsed.toISOString().slice(0, 10);
23
+ }
24
+
25
+ function quoteIdentifier(identifier) {
26
+ if (!/^[a-zA-Z_]\w{0,62}$/.test(identifier)) {
27
+ throw new Error(`Invalid schema name: "${identifier}"`);
28
+ }
29
+ return `"${identifier}"`;
30
+ }
31
+
32
+ function parsePositiveInt(value, fallback) {
33
+ if (value === undefined || value === null || value === true) return fallback;
34
+ const parsed = parseInt(value, 10);
35
+ if (!Number.isFinite(parsed)) return fallback;
36
+ return Math.max(1, parsed);
37
+ }
38
+
18
39
  // ---------------------------------------------------------------------------
19
40
  // Argument parser (minimal, no deps)
20
41
  // ---------------------------------------------------------------------------
@@ -56,7 +77,7 @@ async function cmdRecall(aquifer, args) {
56
77
  }
57
78
 
58
79
  const recallOpts = {
59
- limit: parseInt(args.flags.limit || '5', 10),
80
+ limit: parsePositiveInt(args.flags.limit, 5),
60
81
  agentId: args.flags['agent-id'] || undefined,
61
82
  source: args.flags.source || undefined,
62
83
  dateFrom: args.flags['date-from'] || undefined,
@@ -82,7 +103,7 @@ async function cmdRecall(aquifer, args) {
82
103
  const r = results[i];
83
104
  const ss = r.structuredSummary || {};
84
105
  const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
85
- const date = r.startedAt ? new Date(r.startedAt).toISOString().slice(0, 10) : '?';
106
+ const date = formatDate(r.startedAt, '?');
86
107
  console.log(`${i + 1}. [${r.score?.toFixed(3)}] ${title} (${date}, ${r.agentId})`);
87
108
  if (ss.overview) console.log(` ${ss.overview.slice(0, 200)}`);
88
109
  if (r.matchedTurnText) console.log(` > ${r.matchedTurnText.slice(0, 150)}`);
@@ -113,7 +134,7 @@ async function cmdFeedback(aquifer, args) {
113
134
  }
114
135
 
115
136
  async function cmdBackfill(aquifer, args) {
116
- const limit = parseInt(args.flags.limit || '100', 10);
137
+ const limit = parsePositiveInt(args.flags.limit, 100);
117
138
  const dryRun = !!args.flags['dry-run'];
118
139
  const skipSummary = !!args.flags['skip-summary'];
119
140
  const skipTurnEmbed = !!args.flags['skip-turn-embed'];
@@ -159,13 +180,81 @@ async function cmdStats(aquifer, args) {
159
180
  console.log(`Summaries: ${stats.summaries}`);
160
181
  console.log(`Turn embeddings: ${stats.turnEmbeddings}`);
161
182
  console.log(`Entities: ${stats.entities}`);
162
- if (stats.earliest) console.log(`Range: ${new Date(stats.earliest).toISOString().slice(0, 10)} — ${new Date(stats.latest).toISOString().slice(0, 10)}`);
183
+ if (stats.earliest) console.log(`Range: ${formatDate(stats.earliest, '?')} — ${formatDate(stats.latest, '?')}`);
184
+ }
185
+ }
186
+
187
+ async function cmdQuickstart(aquifer) {
188
+ console.log('Aquifer quickstart — verifying end-to-end setup.\n');
189
+
190
+ // 1. Migrate
191
+ console.log('1/5 Running migrations...');
192
+ await aquifer.migrate();
193
+ console.log(' OK\n');
194
+
195
+ // 2. Commit
196
+ const sessionId = `quickstart-${Date.now()}`;
197
+ console.log('2/5 Committing test session...');
198
+ await aquifer.commit(sessionId, [
199
+ { role: 'user', content: 'We decided to use PostgreSQL with pgvector for the AI memory store instead of a separate vector database.' },
200
+ { role: 'assistant', content: 'Good choice. PG gives us ACID transactions, full-text search, and vector similarity all in one place.' },
201
+ { role: 'user', content: 'The main advantage is turn-level embedding — we can find the exact moment a decision was made.' },
202
+ ], { agentId: 'quickstart', source: 'quickstart' });
203
+ console.log(' OK\n');
204
+
205
+ // 3. Enrich (skip summary — LLM may not be configured)
206
+ console.log('3/5 Enriching (turn embeddings)...');
207
+ const enrichResult = await aquifer.enrich(sessionId, {
208
+ agentId: 'quickstart',
209
+ skipSummary: true,
210
+ skipEntities: true,
211
+ });
212
+ console.log(` OK — ${enrichResult.turnsEmbedded} turns embedded\n`);
213
+
214
+ // 4. Recall
215
+ console.log('4/5 Recalling "PostgreSQL memory store"...');
216
+ const results = await aquifer.recall('PostgreSQL memory store', { limit: 3 });
217
+ if (results.length === 0) {
218
+ console.error(' FAIL — no results returned. Check your embedding config.');
219
+ process.exitCode = 1;
220
+ return;
221
+ }
222
+ console.log(` OK — ${results.length} result(s), top score: ${results[0].score?.toFixed(3)}`);
223
+ if (results[0].matchedTurnText) {
224
+ console.log(` Matched: "${results[0].matchedTurnText.slice(0, 100)}..."`);
163
225
  }
226
+ console.log();
227
+
228
+ // 5. Cleanup
229
+ console.log('5/5 Cleaning up test data...');
230
+ const { Pool } = require('pg');
231
+ const { loadConfig } = require('./shared/config');
232
+ const config = loadConfig();
233
+ const pool = new Pool({ connectionString: config.db.url });
234
+ const schema = quoteIdentifier(config.schema || 'aquifer');
235
+ const tenantId = config.tenantId || 'default';
236
+ try {
237
+ await pool.query('BEGIN');
238
+ await pool.query(
239
+ `DELETE FROM ${schema}.sessions WHERE tenant_id = $1 AND agent_id = $2 AND session_id = $3`,
240
+ [tenantId, 'quickstart', sessionId]
241
+ );
242
+ await pool.query('COMMIT');
243
+ } catch (err) {
244
+ await pool.query('ROLLBACK').catch(() => {});
245
+ throw err;
246
+ } finally {
247
+ await pool.end();
248
+ }
249
+ console.log(' OK\n');
250
+
251
+ console.log('✓ Aquifer is working. You can now start the MCP server:');
252
+ console.log(' npx aquifer mcp');
164
253
  }
165
254
 
166
255
  async function cmdExport(aquifer, args) {
167
256
  const output = args.flags.output || null;
168
- const limit = parseInt(args.flags.limit || '1000', 10);
257
+ const limit = parsePositiveInt(args.flags.limit, 1000);
169
258
 
170
259
  const rows = await aquifer.exportSessions({
171
260
  agentId: args.flags['agent-id'],
@@ -201,6 +290,7 @@ async function main() {
201
290
  console.log(`Usage: aquifer <command> [options]
202
291
 
203
292
  Commands:
293
+ quickstart Verify end-to-end setup (migrate → commit → enrich → recall)
204
294
  migrate Run database migrations
205
295
  recall <query> Search sessions (requires embed config)
206
296
  feedback Record trust feedback on a session
@@ -250,6 +340,9 @@ Options:
250
340
 
251
341
  try {
252
342
  switch (command) {
343
+ case 'quickstart':
344
+ await cmdQuickstart(aquifer);
345
+ break;
253
346
  case 'migrate':
254
347
  await cmdMigrate(aquifer);
255
348
  break;
package/consumers/mcp.js CHANGED
@@ -38,9 +38,11 @@ function formatResults(results, query) {
38
38
  const r = results[i];
39
39
  const ss = r.structuredSummary || {};
40
40
  const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
41
- const date = r.startedAt
42
- ? new Date(r.startedAt).toISOString().slice(0, 10)
43
- : 'unknown';
41
+ let date = 'unknown';
42
+ if (r.startedAt) {
43
+ const parsed = new Date(r.startedAt);
44
+ if (!isNaN(parsed.getTime())) date = parsed.toISOString().slice(0, 10);
45
+ }
44
46
 
45
47
  lines.push(`### ${i + 1}. ${title} (${date}, ${r.agentId || 'default'})`);
46
48
  if (ss.overview || r.summaryText) {
@@ -65,6 +67,8 @@ async function main() {
65
67
  ({ StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js'));
66
68
  ({ z } = require('zod'));
67
69
  } catch (e) {
70
+ const missingDep = e && (e.code === 'MODULE_NOT_FOUND' || /Cannot find module|^missing\b/i.test(e.message || ''));
71
+ if (!missingDep) throw e;
68
72
  process.stderr.write(
69
73
  'aquifer mcp requires @modelcontextprotocol/sdk and zod.\n' +
70
74
  'Install: npm install @modelcontextprotocol/sdk zod\n'
@@ -74,7 +78,7 @@ async function main() {
74
78
 
75
79
  const server = new McpServer({
76
80
  name: 'aquifer-memory',
77
- version: '0.8.0',
81
+ version: '0.9.0',
78
82
  });
79
83
 
80
84
  server.tool(
@@ -79,15 +79,19 @@ function normalizeEntries(rawEntries) {
79
79
  };
80
80
  }
81
81
 
82
+ function formatDate(value) {
83
+ if (!value) return 'unknown';
84
+ const parsed = new Date(value);
85
+ return isNaN(parsed.getTime()) ? 'unknown' : parsed.toISOString().slice(0, 10);
86
+ }
87
+
82
88
  function formatRecallResults(results) {
83
89
  if (results.length === 0) return 'No matching sessions found.';
84
90
 
85
91
  return results.map((r, i) => {
86
92
  const ss = r.structuredSummary || {};
87
93
  const title = ss.title || r.summaryText?.slice(0, 60) || '(untitled)';
88
- const date = r.startedAt
89
- ? new Date(r.startedAt).toISOString().slice(0, 10)
90
- : 'unknown';
94
+ const date = formatDate(r.startedAt);
91
95
 
92
96
  const lines = [`### ${i + 1}. ${title} (${date}, ${r.agentId || 'default'})`];
93
97
  if (ss.overview || r.summaryText) {
package/core/aquifer.js CHANGED
@@ -583,7 +583,22 @@ function createAquifer(config) {
583
583
  weights: overrideWeights,
584
584
  entities: explicitEntities,
585
585
  entityMode = 'any',
586
+ strictSearchErrors = false,
586
587
  } = opts;
588
+ const searchErrors = [];
589
+
590
+ function recordSearchError(pathName, err) {
591
+ searchErrors.push({
592
+ path: pathName,
593
+ message: err && err.message ? err.message : String(err),
594
+ });
595
+ }
596
+
597
+ function maybeThrowSearchErrors() {
598
+ if (!strictSearchErrors || searchErrors.length === 0) return;
599
+ const details = searchErrors.map(e => `${e.path}: ${e.message}`).join('; ');
600
+ throw new Error(`Recall search failed: ${details}`);
601
+ }
587
602
 
588
603
  // Normalize agentId/agentIds into a single resolved value
589
604
  // agentIds takes precedence; agentId is sugar for agentIds: [agentId]
@@ -692,17 +707,26 @@ function createAquifer(config) {
692
707
  runFts
693
708
  ? storage.searchSessions(pool, query, {
694
709
  schema, tenantId, agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit, ftsConfig,
695
- }).catch(() => [])
710
+ }).catch((err) => {
711
+ recordSearchError('fts', err);
712
+ return [];
713
+ })
696
714
  : Promise.resolve([]),
697
715
  runVector
698
716
  ? embeddingSearchSummaries(queryVec, {
699
717
  agentIds: resolvedAgentIds, source, dateFrom, dateTo, limit: fetchLimit,
700
- }).catch(() => [])
718
+ }).catch((err) => {
719
+ recordSearchError('summary-vector', err);
720
+ return [];
721
+ })
701
722
  : Promise.resolve([]),
702
723
  runVector
703
724
  ? storage.searchTurnEmbeddings(pool, {
704
725
  schema, tenantId, queryVec, dateFrom, dateTo, agentIds: resolvedAgentIds, source, limit: fetchLimit,
705
- }).catch(() => ({ rows: [] }))
726
+ }).catch((err) => {
727
+ recordSearchError('turn-vector', err);
728
+ return { rows: [] };
729
+ })
706
730
  : Promise.resolve({ rows: [] }),
707
731
  ]);
708
732
 
@@ -718,6 +742,7 @@ function createAquifer(config) {
718
742
  const filteredTurn = filterFn(turnRows);
719
743
 
720
744
  if (filteredFts.length === 0 && filteredEmb.length === 0 && filteredTurn.length === 0) {
745
+ maybeThrowSearchErrors();
721
746
  return [];
722
747
  }
723
748
 
@@ -737,7 +762,7 @@ function createAquifer(config) {
737
762
  const EXTERNAL_TIMEOUT = 10000;
738
763
  const externalRows = [];
739
764
  const externalPromises = [];
740
- for (const [, sourceConfig] of sources) {
765
+ for (const [name, sourceConfig] of sources) {
741
766
  if (typeof sourceConfig.search === 'function') {
742
767
  const w = sourceConfig.weight !== null && sourceConfig.weight !== undefined ? sourceConfig.weight : 1.0;
743
768
  externalPromises.push(
@@ -750,7 +775,9 @@ function createAquifer(config) {
750
775
  if (r && r.session_id) externalRows.push({ ...r, _externalWeight: w });
751
776
  }
752
777
  }
753
- }).catch(() => { /* external source failure/timeout non-fatal */ })
778
+ }).catch((err) => {
779
+ recordSearchError(`external:${name}`, err);
780
+ })
754
781
  );
755
782
  }
756
783
  }
@@ -835,6 +862,7 @@ function createAquifer(config) {
835
862
  hybridScore: r._hybridScore ?? r._score,
836
863
  rerankScore: r._rerankScore ?? null,
837
864
  rerankFallback: r._rerankFallback || false,
865
+ searchErrors: searchErrors.slice(),
838
866
  },
839
867
  }));
840
868
  },
@@ -36,12 +36,12 @@ function rrfFusion(ftsResults = [], embResults = [], turnResults = [], K = 60) {
36
36
  // timeDecay — sigmoid decay based on age in days
37
37
  // ---------------------------------------------------------------------------
38
38
 
39
- function timeDecay(startedAt, midpointDays = 45, steepness = 0.05) {
39
+ function timeDecay(startedAt, midpointDays = 45, steepness = 0.05, nowMs = Date.now()) {
40
40
  if (!startedAt) return 0.5;
41
41
  const dt = typeof startedAt === 'string' ? new Date(startedAt) : startedAt;
42
42
  if (isNaN(dt.getTime())) return 0.5;
43
43
 
44
- const ageDays = (Date.now() - dt.getTime()) / (1000 * 60 * 60 * 24);
44
+ const ageDays = (nowMs - dt.getTime()) / (1000 * 60 * 60 * 24);
45
45
  return 1 / (1 + Math.exp(steepness * (ageDays - midpointDays)));
46
46
  }
47
47
 
@@ -49,14 +49,14 @@ function timeDecay(startedAt, midpointDays = 45, steepness = 0.05) {
49
49
  // accessScore — exponential decay on access recency (30-day half-life)
50
50
  // ---------------------------------------------------------------------------
51
51
 
52
- function accessScore(accessCount, lastAccessedAt) {
52
+ function accessScore(accessCount, lastAccessedAt, nowMs = Date.now()) {
53
53
  if (!accessCount || accessCount <= 0) return 0;
54
54
  if (!lastAccessedAt) return 0;
55
55
 
56
56
  const dt = typeof lastAccessedAt === 'string' ? new Date(lastAccessedAt) : lastAccessedAt;
57
57
  if (isNaN(dt.getTime())) return 0;
58
58
 
59
- const daysSince = (Date.now() - dt.getTime()) / (1000 * 60 * 60 * 24);
59
+ const daysSince = (nowMs - dt.getTime()) / (1000 * 60 * 60 * 24);
60
60
  return accessCount * Math.exp(-0.693 * daysSince / 30);
61
61
  }
62
62
 
@@ -89,6 +89,7 @@ function hybridRank(ftsResults, embResults, turnResults, opts = {}) {
89
89
  } = opts;
90
90
 
91
91
  const w = { ...DEFAULT_WEIGHTS, ...weights };
92
+ const nowMs = opts.nowMs ?? Date.now();
92
93
 
93
94
  // Build allResults map: session_id → result object
94
95
  const allResults = new Map();
@@ -140,11 +141,12 @@ function hybridRank(ftsResults, embResults, turnResults, opts = {}) {
140
141
  const rawRrf = rrfScores.get(sessionId) || 0;
141
142
  const normRrf = maxRrf > 0 ? rawRrf / maxRrf : 0;
142
143
 
143
- const td = timeDecay(result.started_at);
144
+ const td = timeDecay(result.started_at, 45, 0.05, nowMs);
144
145
 
145
146
  const accessEff = accessScore(
146
147
  result.access_count || 0,
147
148
  result.last_accessed_at,
149
+ nowMs,
148
150
  );
149
151
  const as = 1 - Math.exp(-accessEff / 5);
150
152
 
package/docs/setup.md ADDED
@@ -0,0 +1,194 @@
1
+ # Aquifer Setup Guide
2
+
3
+ This guide walks you through installing Aquifer and verifying a complete write → enrich → recall cycle. By the end, you will have a working MCP memory server that an agent host can connect to.
4
+
5
+ ## Prerequisites
6
+
7
+ You need three things running before Aquifer can work:
8
+
9
+ 1. **PostgreSQL 15+** with the **pgvector** extension installed
10
+ 2. **Node.js 18+**
11
+ 3. **An embedding endpoint** — Ollama (local), OpenAI, or any OpenAI-compatible API
12
+
13
+ ## Step 1: Database
14
+
15
+ ### Option A: Docker (recommended for local dev)
16
+
17
+ The repo includes a `docker-compose.yml` that starts PostgreSQL 16 with pgvector and Ollama with bge-m3 auto-pulled:
18
+
19
+ ```bash
20
+ cd /path/to/aquifer
21
+ docker compose up -d
22
+ ```
23
+
24
+ This gives you a database at `postgresql://aquifer:aquifer@localhost:5432/aquifer` with pgvector ready, plus an Ollama server with bge-m3 for embeddings. First run takes a few minutes while the model downloads.
25
+
26
+ ### Option B: Existing PostgreSQL
27
+
28
+ Make sure pgvector is installed. Connect as a superuser and run:
29
+
30
+ ```sql
31
+ CREATE EXTENSION IF NOT EXISTS vector;
32
+ ```
33
+
34
+ If your PostgreSQL was installed from a package manager, you may need to install the pgvector package separately. See [pgvector installation](https://github.com/pgvector/pgvector#installation).
35
+
36
+ ## Step 2: Install Aquifer
37
+
38
+ ```bash
39
+ npm install @shadowforge0/aquifer-memory
40
+ ```
41
+
42
+ All dependencies including MCP SDK and zod are installed automatically.
43
+
44
+ ## Step 3: Configure
45
+
46
+ Aquifer reads configuration from three sources (in priority order):
47
+
48
+ 1. Config file: `aquifer.config.json` in the working directory, or set `AQUIFER_CONFIG=/path/to/config.json`
49
+ 2. Environment variables (see below)
50
+ 3. Programmatic overrides via `createAquifer()`
51
+
52
+ ### Minimum env vars for MCP recall
53
+
54
+ ```bash
55
+ export DATABASE_URL="postgresql://aquifer:aquifer@localhost:5432/aquifer"
56
+ export AQUIFER_EMBED_BASE_URL="http://localhost:11434/v1"
57
+ export AQUIFER_EMBED_MODEL="bge-m3"
58
+ ```
59
+
60
+ ### Optional but common
61
+
62
+ ```bash
63
+ # PG schema (default: aquifer) — useful for running multiple instances in one database
64
+ export AQUIFER_SCHEMA="aquifer"
65
+
66
+ # LLM for built-in summarization — without this, enrich requires a custom summaryFn
67
+ export AQUIFER_LLM_BASE_URL="http://localhost:11434/v1"
68
+ export AQUIFER_LLM_MODEL="llama3.1"
69
+
70
+ # Knowledge graph
71
+ export AQUIFER_ENTITIES_ENABLED="true"
72
+ ```
73
+
74
+ Copy `.env.example` from the repo root for a full annotated list.
75
+
76
+ ## Step 4: Verify everything works
77
+
78
+ ```bash
79
+ npx aquifer quickstart
80
+ ```
81
+
82
+ This single command runs migrations, commits a test session, embeds it, recalls it, and cleans up. If it prints `✓ Aquifer is working`, your setup is correct.
83
+
84
+ You can also run individual steps manually: `npx aquifer migrate`, `npx aquifer stats`, etc.
85
+
86
+ ## Step 5: Start the MCP server
87
+
88
+ ```bash
89
+ npx aquifer mcp
90
+ ```
91
+
92
+ The server starts on stdio and waits for MCP client connections. There is no visible output on success — the server is ready when the process stays running without error.
93
+
94
+ ### Verify with the library API (optional)
95
+
96
+ If you want to test the library directly instead of the CLI:
97
+
98
+ ```javascript
99
+ const { createAquifer, createEmbedder } = require('@shadowforge0/aquifer-memory');
100
+
101
+ const embedder = createEmbedder({
102
+ provider: 'ollama',
103
+ ollamaUrl: 'http://localhost:11434',
104
+ model: 'bge-m3',
105
+ });
106
+
107
+ const aquifer = createAquifer({
108
+ db: process.env.DATABASE_URL,
109
+ schema: 'aquifer',
110
+ embed: { fn: (texts) => embedder.embedBatch(texts) },
111
+ });
112
+
113
+ await aquifer.migrate();
114
+
115
+ // Commit a test session
116
+ await aquifer.commit('test-001', [
117
+ { role: 'user', content: 'We decided to use PostgreSQL for the memory store.' },
118
+ { role: 'assistant', content: 'Good choice — PG gives us ACID, FTS, and pgvector in one place.' },
119
+ ], { agentId: 'test' });
120
+
121
+ // Enrich (embed turns — summarization needs LLM config)
122
+ await aquifer.enrich('test-001', { agentId: 'test', skipSummary: true });
123
+
124
+ // Recall
125
+ const results = await aquifer.recall('PostgreSQL memory', { limit: 3 });
126
+ console.log('Results:', results.length); // Should be >= 1
127
+
128
+ await aquifer.close();
129
+ ```
130
+
131
+ ## Connecting a host
132
+
133
+ Once the MCP server is verified, connect your agent host:
134
+
135
+ ### Claude Code
136
+
137
+ Add to `.claude.json` (project-level) or user-level MCP config:
138
+
139
+ ```json
140
+ {
141
+ "mcpServers": {
142
+ "aquifer": {
143
+ "type": "stdio",
144
+ "command": "node",
145
+ "args": ["/absolute/path/to/aquifer/consumers/mcp.js"],
146
+ "env": {
147
+ "DATABASE_URL": "postgresql://aquifer:aquifer@localhost:5432/aquifer",
148
+ "AQUIFER_EMBED_BASE_URL": "http://localhost:11434/v1",
149
+ "AQUIFER_EMBED_MODEL": "bge-m3"
150
+ }
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ Tools appear as `mcp__aquifer__session_recall`, `mcp__aquifer__session_feedback`, `mcp__aquifer__memory_stats`, `mcp__aquifer__memory_pending`.
157
+
158
+ ### OpenClaw
159
+
160
+ Add to `openclaw.json`:
161
+
162
+ ```json
163
+ {
164
+ "mcp": {
165
+ "servers": {
166
+ "aquifer": {
167
+ "command": "node",
168
+ "args": ["/absolute/path/to/aquifer/consumers/mcp.js"],
169
+ "env": {
170
+ "DATABASE_URL": "postgresql://...",
171
+ "AQUIFER_EMBED_BASE_URL": "http://localhost:11434/v1",
172
+ "AQUIFER_EMBED_MODEL": "bge-m3"
173
+ }
174
+ }
175
+ }
176
+ }
177
+ }
178
+ ```
179
+
180
+ Tools materialize as `aquifer__session_recall`, `aquifer__session_feedback`, `aquifer__memory_stats`, `aquifer__memory_pending`.
181
+
182
+ Do **not** use the OpenClaw plugin (`consumers/openclaw-plugin.js`) for tool delivery. The plugin is retained for session capture via `before_reset` only.
183
+
184
+ ## Troubleshooting
185
+
186
+ **`error: type "vector" does not exist`** — pgvector is not installed. Use the `pgvector/pgvector` Docker image, or install the extension manually: `CREATE EXTENSION IF NOT EXISTS vector;` (requires superuser).
187
+
188
+ **`aquifer mcp requires @modelcontextprotocol/sdk and zod`** — These are regular dependencies and should be installed automatically. Run `npm install` again to ensure all deps are present.
189
+
190
+ **Recall returns empty results** — Sessions must be enriched before they are searchable. Run `npx aquifer stats` and check that summaries and/or turn embeddings exist. If not, run `npx aquifer backfill` to enrich pending sessions.
191
+
192
+ **`ECONNREFUSED` on embed calls** — Your embedding endpoint is not reachable. For Ollama: make sure it is running (`ollama serve`) and the model is pulled (`ollama pull bge-m3`).
193
+
194
+ **Enrich fails with "no LLM configured"** — The built-in summarizer needs `AQUIFER_LLM_BASE_URL` + `AQUIFER_LLM_MODEL`. Alternatively, pass `skipSummary: true` to enrich without summarization (turn embeddings still work), or provide your own `summaryFn`.
package/package.json CHANGED
@@ -1,14 +1,16 @@
1
1
  {
2
2
  "name": "@shadowforge0/aquifer-memory",
3
- "version": "0.8.0",
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.",
3
+ "version": "0.9.1",
4
+ "description": "PG-native long-term memory for AI agents. Turn-level embedding, hybrid RRF ranking, optional knowledge graph. MCP server, CLI, and library API.",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js",
8
8
  "core/",
9
9
  "pipeline/",
10
10
  "schema/",
11
- "consumers/"
11
+ "consumers/",
12
+ "docs/",
13
+ "scripts/"
12
14
  ],
13
15
  "bin": {
14
16
  "aquifer": "./consumers/cli.js"
@@ -32,10 +34,8 @@
32
34
  },
33
35
  "author": "shadowforge0",
34
36
  "dependencies": {
35
- "pg": "^8.13.0"
36
- },
37
- "optionalDependencies": {
38
37
  "@modelcontextprotocol/sdk": "^1.29.0",
38
+ "pg": "^8.13.0",
39
39
  "zod": "^3.25.76"
40
40
  },
41
41
  "engines": {
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Aquifer smoke test — validates the full write → enrich → recall cycle.
5
+ *
6
+ * Prerequisites:
7
+ * - DATABASE_URL set to a PostgreSQL database with pgvector
8
+ * - AQUIFER_EMBED_BASE_URL + AQUIFER_EMBED_MODEL set (e.g., Ollama bge-m3)
9
+ *
10
+ * Usage:
11
+ * node scripts/smoke.mjs
12
+ */
13
+
14
+ import { createRequire } from 'module';
15
+ const require = createRequire(import.meta.url);
16
+
17
+ const { createAquifer, createEmbedder } = require('../index.js');
18
+ const { loadConfig } = require('../consumers/shared/config.js');
19
+
20
+ const config = loadConfig();
21
+
22
+ if (!config.db.url) {
23
+ console.error('ERROR: DATABASE_URL is not set.');
24
+ process.exit(1);
25
+ }
26
+
27
+ if (!config.embed.baseUrl || !config.embed.model) {
28
+ console.error('ERROR: AQUIFER_EMBED_BASE_URL and AQUIFER_EMBED_MODEL must be set.');
29
+ process.exit(1);
30
+ }
31
+
32
+ // Build embedder
33
+ const isOllama = config.embed.baseUrl.includes('11434') || config.embed.baseUrl.includes('ollama');
34
+ const embedder = isOllama
35
+ ? createEmbedder({
36
+ provider: 'ollama',
37
+ ollamaUrl: config.embed.baseUrl.replace(/\/v1\/?$/, ''),
38
+ model: config.embed.model,
39
+ })
40
+ : createEmbedder({
41
+ provider: 'openai',
42
+ openaiApiKey: config.embed.apiKey || '',
43
+ openaiModel: config.embed.model,
44
+ });
45
+
46
+ const aquifer = createAquifer({
47
+ db: config.db.url,
48
+ schema: config.schema || 'aquifer',
49
+ tenantId: config.tenantId || 'default',
50
+ embed: { fn: (texts) => embedder.embedBatch(texts), dim: config.embed.dim || null },
51
+ entities: { enabled: false },
52
+ });
53
+
54
+ const SESSION_ID = `smoke-test-${Date.now()}`;
55
+
56
+ try {
57
+ // 1. Migrate
58
+ console.log('1. Running migrations...');
59
+ await aquifer.migrate();
60
+ console.log(' OK');
61
+
62
+ // 2. Commit a test session
63
+ console.log('2. Committing test session...');
64
+ const commitResult = await aquifer.commit(SESSION_ID, [
65
+ { role: 'user', content: 'We decided to use PostgreSQL with pgvector for the AI memory store instead of a separate vector database.' },
66
+ { role: 'assistant', content: 'Good choice. PG gives us ACID transactions, full-text search, and vector similarity all in one place.' },
67
+ { role: 'user', content: 'The main advantage is turn-level embedding — we can find the exact moment a decision was made.' },
68
+ ], { agentId: 'smoke-test', source: 'smoke' });
69
+ console.log(` OK — session ${commitResult.isNew ? 'created' : 'updated'}`);
70
+
71
+ // 3. Enrich (skip summary since LLM may not be configured)
72
+ console.log('3. Enriching (turn embeddings, skip summary)...');
73
+ const enrichResult = await aquifer.enrich(SESSION_ID, {
74
+ agentId: 'smoke-test',
75
+ skipSummary: true,
76
+ skipEntities: true,
77
+ });
78
+ console.log(` OK — ${enrichResult.turnsEmbedded} turns embedded`);
79
+
80
+ // 4. Recall
81
+ console.log('4. Recalling "PostgreSQL memory store"...');
82
+ const results = await aquifer.recall('PostgreSQL memory store', { limit: 3 });
83
+ if (results.length === 0) {
84
+ console.error(' FAIL — no results returned');
85
+ process.exit(1);
86
+ }
87
+ console.log(` OK — ${results.length} result(s), top score: ${results[0].score?.toFixed(3)}`);
88
+ if (results[0].matchedTurnText) {
89
+ console.log(` Matched turn: "${results[0].matchedTurnText.slice(0, 100)}..."`);
90
+ }
91
+
92
+ // 5. Stats
93
+ console.log('5. Checking stats...');
94
+ const stats = await aquifer.getStats();
95
+ console.log(` Sessions: ${stats.sessionTotal}, Turn embeddings: ${stats.turnEmbeddings}`);
96
+
97
+ // 6. Cleanup — remove smoke test session
98
+ console.log('6. Cleaning up...');
99
+ const { Pool } = require('pg');
100
+ const pool = new Pool({ connectionString: config.db.url });
101
+ const schema = config.schema || 'aquifer';
102
+ await pool.query(`DELETE FROM ${schema}.turn_embeddings WHERE session_id IN (SELECT id FROM ${schema}.sessions WHERE session_id = $1)`, [SESSION_ID]);
103
+ await pool.query(`DELETE FROM ${schema}.session_summaries WHERE session_id IN (SELECT id FROM ${schema}.sessions WHERE session_id = $1)`, [SESSION_ID]);
104
+ await pool.query(`DELETE FROM ${schema}.sessions WHERE session_id = $1`, [SESSION_ID]);
105
+ await pool.end();
106
+ console.log(' OK');
107
+
108
+ console.log('\n✓ smoke test passed');
109
+ } catch (err) {
110
+ console.error(`\n✗ smoke test failed: ${err.message}`);
111
+ if (err.stack) console.error(err.stack);
112
+ process.exit(1);
113
+ } finally {
114
+ await aquifer.close();
115
+ }