persyst-mcp 2.1.1 → 2.1.2

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.
@@ -1,38 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * persyst-hook.js — Claude Code Hook for Persyst Memory
4
+ * persyst-hook.js — Claude Code Hook for Persyst Memory (PAMP-Enhanced)
5
5
  *
6
6
  * Automatically injects relevant memories into Claude Code's context
7
- * on SessionStart and UserPromptSubmit events.
7
+ * on SessionStart and UserPromptSubmit events, and queues conversation
8
+ * turns for async background extraction on Stop events.
9
+ *
10
+ * PAMP Integration (Persyst Auto-Memory Pipeline):
11
+ * - Tier 1: Agent-explicit add_memory calls (existing, unchanged)
12
+ * - Tier 2: Heuristic regex extraction on UserPromptSubmit (sync, zero-cost)
13
+ * - Tier 3: Async LLM extraction via background worker (spawned on Stop)
8
14
  *
9
15
  * How it works:
10
- * 1. Claude Code sends a JSON payload on stdin with hook_event_name, session_id, cwd, etc.
16
+ * 1. Claude Code sends a JSON payload on stdin with hook_event_name, session_id, etc.
11
17
  * 2. This script connects to the Persyst MCP server via StdioClientTransport.
12
18
  * 3. It calls get_optimized_context or search_memories to retrieve relevant memories.
13
19
  * 4. It returns a JSON response on stdout with additionalContext for Claude Code to inject.
20
+ * 5. On Stop: queues the conversation text for background LLM extraction.
14
21
  *
15
22
  * Installation:
16
23
  * npx persyst-mcp setup
17
24
  *
18
25
  * Manual registration in ~/.claude/settings.json:
19
- * { "hooks": { "SessionStart": [...], "UserPromptSubmit": [...] } }
26
+ * { "hooks": { "SessionStart": [...], "UserPromptSubmit": [...], "Stop": [...] } }
20
27
  */
21
28
 
22
29
  import { Client } from '@modelcontextprotocol/sdk/client/index.js';
23
30
  import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
24
31
  import { fileURLToPath } from 'url';
25
- import { dirname, resolve } from 'path';
32
+ import { dirname, resolve, join } from 'path';
33
+ import { spawn } from 'child_process';
34
+ import { writeFileSync, readdirSync, mkdirSync, existsSync } from 'fs';
35
+ import { homedir } from 'os';
26
36
 
27
37
  const __filename = fileURLToPath(import.meta.url);
28
38
  const __dirname = dirname(__filename);
29
39
 
40
+ // ============================================================
41
+ // CONFIGURATION
42
+ // ============================================================
43
+
30
44
  // Minimum prompt length to trigger memory search (skip "y", "ok", "/run", etc.)
31
45
  const MIN_PROMPT_LENGTH = 15;
32
46
 
33
47
  // Maximum time to wait for Persyst MCP connection (ms)
34
48
  const CONNECTION_TIMEOUT = 8000;
35
49
 
50
+ // Hard timeout for the entire hook execution (ms)
51
+ // Claude Code will kill the hook if it exceeds this
52
+ const MAX_HOOK_LATENCY_MS = 500;
53
+
54
+ // Maximum active queue jobs before skipping worker spawn
55
+ const MAX_QUEUE_JOBS = 20;
56
+
57
+ // Queue directory for background extraction jobs
58
+ const QUEUE_DIR = join(homedir(), '.persyst', 'queue');
59
+
60
+ // ============================================================
61
+ // STDIN READER
62
+ // ============================================================
63
+
36
64
  /**
37
65
  * Read the full JSON payload from stdin.
38
66
  * Claude Code sends the hook context as a single JSON object.
@@ -53,13 +81,20 @@ function readStdin() {
53
81
  });
54
82
  }
55
83
 
84
+ // ============================================================
85
+ // MCP CLIENT CONNECTION
86
+ // ============================================================
87
+
56
88
  /**
57
89
  * Connect to the Persyst MCP server as a client.
58
90
  * Uses StdioClientTransport to spawn and communicate with the server.
59
91
  */
60
92
  async function connectToPersyst() {
61
- // Resolve the path to Persyst's index.js relative to this hook file
62
- const persystPath = resolve(__dirname, '..', 'index.js');
93
+ // Resolve the path to Persyst's index.js
94
+ let persystPath = '{{PERSYST_INDEX_PATH}}';
95
+ if (persystPath.startsWith('{{')) {
96
+ persystPath = resolve(__dirname, '..', 'index.js');
97
+ }
63
98
 
64
99
  const transport = new StdioClientTransport({
65
100
  command: 'node',
@@ -93,6 +128,89 @@ async function callTool(client, toolName, args) {
93
128
  return null;
94
129
  }
95
130
 
131
+ // ============================================================
132
+ // PAMP: QUEUE MANAGEMENT
133
+ // ============================================================
134
+
135
+ /**
136
+ * Count active job files in the queue directory.
137
+ * Used for worker pool protection — don't spawn if overloaded.
138
+ * @returns {number}
139
+ */
140
+ function countQueueJobs() {
141
+ try {
142
+ if (!existsSync(QUEUE_DIR)) return 0;
143
+ return readdirSync(QUEUE_DIR).filter(f => f.endsWith('.json')).length;
144
+ } catch (_) {
145
+ return 0;
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Write a conversation turn to the extraction queue.
151
+ * @param {string} text - The conversation text to extract from
152
+ * @param {Object} meta - Metadata (session_id, agent_id, etc.)
153
+ */
154
+ function enqueueJob(text, meta = {}) {
155
+ try {
156
+ mkdirSync(QUEUE_DIR, { recursive: true });
157
+
158
+ const jobId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
159
+ const jobFile = join(QUEUE_DIR, `${jobId}.json`);
160
+
161
+ writeFileSync(jobFile, JSON.stringify({
162
+ text,
163
+ session_id: meta.session_id || null,
164
+ agent_id: meta.agent_id || 'claude-code',
165
+ namespace: meta.namespace || 'shared',
166
+ cwd: meta.cwd || null,
167
+ queued_at: new Date().toISOString(),
168
+ _retries: 0
169
+ }, null, 2));
170
+
171
+ return jobId;
172
+ } catch (err) {
173
+ // Non-critical — log and continue
174
+ process.stderr.write(`[persyst-hook] Queue write failed: ${err.message}\n`);
175
+ return null;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Spawn the background extraction worker as a detached process.
181
+ * The worker runs independently — hook doesn't wait for it.
182
+ */
183
+ function spawnWorker() {
184
+ // Check queue depth first
185
+ const queueDepth = countQueueJobs();
186
+ if (queueDepth > MAX_QUEUE_JOBS) {
187
+ process.stderr.write(`[persyst-hook] Queue overloaded (${queueDepth} jobs), skipping worker spawn.\n`);
188
+ return;
189
+ }
190
+
191
+ try {
192
+ let workerPath = '{{PERSYST_WORKER_PATH}}';
193
+ if (workerPath.startsWith('{{')) {
194
+ workerPath = resolve(__dirname, '..', 'bin', 'extract-worker.js');
195
+ }
196
+
197
+ const child = spawn('node', [workerPath], {
198
+ detached: true,
199
+ stdio: 'ignore',
200
+ env: { ...process.env }
201
+ });
202
+
203
+ // Unref so the hook can exit without waiting for the worker
204
+ child.unref();
205
+ } catch (err) {
206
+ process.stderr.write(`[persyst-hook] Worker spawn failed: ${err.message}\n`);
207
+ }
208
+ }
209
+
210
+ // ============================================================
211
+ // EVENT HANDLERS
212
+ // ============================================================
213
+
96
214
  /**
97
215
  * Handle SessionStart: load project-wide context and ingest git history.
98
216
  */
@@ -152,6 +270,7 @@ async function handleSessionStart(client, input) {
152
270
 
153
271
  /**
154
272
  * Handle UserPromptSubmit: search for memories relevant to the user's prompt.
273
+ * Also runs Tier 2 heuristic extraction inline (zero-cost).
155
274
  */
156
275
  async function handleUserPromptSubmit(client, input) {
157
276
  const prompt = input.prompt || '';
@@ -161,6 +280,25 @@ async function handleUserPromptSubmit(client, input) {
161
280
  return {};
162
281
  }
163
282
 
283
+ // --- Tier 2: Run heuristic extraction inline (sync, zero-cost) ---
284
+ // We don't store results here — we queue them alongside the LLM job.
285
+ // This just detects if there's extractable signal in the prompt.
286
+ let heuristicFacts = [];
287
+ try {
288
+ const { extractHeuristic } = await import('../src/extractor-heuristic.js');
289
+ heuristicFacts = extractHeuristic(prompt);
290
+ } catch (_) {
291
+ // Heuristic module not available — Tier 3 will handle it
292
+ }
293
+
294
+ // Queue the prompt for Tier 3 background extraction (non-blocking)
295
+ enqueueJob(prompt, {
296
+ session_id: input.session_id,
297
+ agent_id: 'claude-code',
298
+ cwd: input.cwd
299
+ });
300
+
301
+ // --- Memory Retrieval (existing behavior) ---
164
302
  // Use search_memories for speed on per-prompt lookups (faster than get_optimized_context)
165
303
  const searchResult = await callTool(client, 'search_memories', {
166
304
  query: prompt.slice(0, 200), // Truncate very long prompts for search efficiency
@@ -178,6 +316,13 @@ async function handleUserPromptSubmit(client, input) {
178
316
  for (const mem of searchResult.results) {
179
317
  contextLines.push(`• [Memory #${mem.id}] ${mem.content}`);
180
318
  }
319
+
320
+ // Add heuristic extraction notice if any facts were detected
321
+ if (heuristicFacts.length > 0) {
322
+ contextLines.push('');
323
+ contextLines.push(`[PAMP: ${heuristicFacts.length} fact signal(s) detected, queued for extraction]`);
324
+ }
325
+
181
326
  contextLines.push('=== END MEMORY ===');
182
327
 
183
328
  return {
@@ -189,8 +334,31 @@ async function handleUserPromptSubmit(client, input) {
189
334
  }
190
335
 
191
336
  /**
192
- * Main entry point.
337
+ * Handle Stop: queue the final conversation turn for background extraction
338
+ * and spawn the worker to process the queue.
193
339
  */
340
+ async function handleStop(input) {
341
+ // The Stop event may include conversation_turns or transcript data
342
+ const transcript = input.transcript || input.conversation || '';
343
+
344
+ if (transcript && typeof transcript === 'string' && transcript.length > MIN_PROMPT_LENGTH) {
345
+ enqueueJob(transcript, {
346
+ session_id: input.session_id,
347
+ agent_id: 'claude-code',
348
+ cwd: input.cwd
349
+ });
350
+ }
351
+
352
+ // Spawn background worker to process all queued jobs
353
+ spawnWorker();
354
+
355
+ return {};
356
+ }
357
+
358
+ // ============================================================
359
+ // MAIN ENTRY POINT
360
+ // ============================================================
361
+
194
362
  async function main() {
195
363
  let client = null;
196
364
 
@@ -198,20 +366,37 @@ async function main() {
198
366
  const input = await readStdin();
199
367
  const eventName = input.hook_event_name;
200
368
 
369
+ // Handle Stop event without MCP connection (just queue + spawn)
370
+ if (eventName === 'Stop') {
371
+ const response = await handleStop(input);
372
+ console.log(JSON.stringify(response));
373
+ return;
374
+ }
375
+
201
376
  // Only handle events we care about
202
377
  if (eventName !== 'SessionStart' && eventName !== 'UserPromptSubmit') {
203
378
  console.log(JSON.stringify({}));
204
379
  return;
205
380
  }
206
381
 
207
- // Connect to Persyst
382
+ // Connect to Persyst with hard timeout
383
+ const hookStart = Date.now();
208
384
  client = await connectToPersyst();
209
385
 
210
386
  let response;
211
387
  if (eventName === 'SessionStart') {
212
388
  response = await handleSessionStart(client, input);
213
389
  } else if (eventName === 'UserPromptSubmit') {
214
- response = await handleUserPromptSubmit(client, input);
390
+ // Apply hard timeout for prompt-time hook execution
391
+ response = await Promise.race([
392
+ handleUserPromptSubmit(client, input),
393
+ new Promise((resolve) =>
394
+ setTimeout(() => {
395
+ process.stderr.write(`[persyst-hook] UserPromptSubmit hit ${MAX_HOOK_LATENCY_MS}ms timeout, returning partial.\n`);
396
+ resolve({});
397
+ }, MAX_HOOK_LATENCY_MS - (Date.now() - hookStart))
398
+ )
399
+ ]);
215
400
  } else {
216
401
  response = {};
217
402
  }
package/index.js CHANGED
@@ -32,6 +32,13 @@ if (subcommand === 'setup') {
32
32
  // Shift 'ingest' from process.argv so ingest.js gets the correct arguments
33
33
  process.argv.splice(2, 1);
34
34
  await import('./bin/ingest.js');
35
+ } else if (subcommand === 'extract') {
36
+ // Shift 'extract' from process.argv so extract.js gets the correct arguments
37
+ process.argv.splice(2, 1);
38
+ await import('./bin/extract.js');
39
+ } else if (subcommand === 'worker') {
40
+ // Run the background extraction worker directly
41
+ await import('./bin/extract-worker.js');
35
42
  } else {
36
43
  // Default: start the MCP server
37
44
  const { startServer } = await import('./src/server.js');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "persyst-mcp",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Local-first MCP memory server with hybrid keyword + semantic search for coding agents",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,7 +9,9 @@
9
9
  "persyst-setup": "bin/setup.js",
10
10
  "persyst-aider": "bin/aider.js",
11
11
  "persyst-init": "bin/init.js",
12
- "persyst-ingest": "bin/ingest.js"
12
+ "persyst-ingest": "bin/ingest.js",
13
+ "persyst-extract": "bin/extract.js",
14
+ "persyst-worker": "bin/extract-worker.js"
13
15
  },
14
16
  "engines": {
15
17
  "node": ">=18.0.0"
@@ -25,7 +27,9 @@
25
27
  "scripts": {
26
28
  "start": "node index.js",
27
29
  "test": "node test/smoke.js",
28
- "test:heavy": "cross-env NODE_ENV=test node --test test/test_*.js"
30
+ "test:heavy": "cross-env NODE_ENV=test node --test test/test_*.js",
31
+ "worker": "node bin/extract-worker.js",
32
+ "extract": "node bin/extract.js"
29
33
  },
30
34
  "keywords": [
31
35
  "mcp",
package/src/database.js CHANGED
@@ -72,6 +72,16 @@ try {
72
72
  db.exec('ALTER TABLE memories ADD COLUMN assertion_time INTEGER DEFAULT (unixepoch())');
73
73
  } catch (e) { /* Column already exists */ }
74
74
 
75
+ // --- Migration: add namespace column for per-agent isolation ---
76
+ try {
77
+ db.exec("ALTER TABLE memories ADD COLUMN namespace TEXT DEFAULT 'shared'");
78
+ } catch (e) { /* Column already exists */ }
79
+
80
+ // --- Index on namespace for fast filtered queries ---
81
+ try {
82
+ db.exec('CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories (namespace)');
83
+ } catch (e) { /* Index already exists */ }
84
+
75
85
  // --- Contradictions table ---
76
86
  db.exec(`
77
87
  CREATE TABLE IF NOT EXISTS contradictions (
@@ -208,7 +218,7 @@ console.error('[persyst] Schema initialized ✓');
208
218
  const stmts = {
209
219
  // -- Insert --
210
220
  insertMemory: db.prepare(
211
- 'INSERT INTO memories (content, importance_score) VALUES (?, ?)'
221
+ 'INSERT INTO memories (content, importance_score, namespace) VALUES (?, ?, ?)'
212
222
  ),
213
223
  insertVec: db.prepare(
214
224
  'INSERT INTO memories_vec (rowid, embedding) VALUES (?, ?)'
@@ -246,15 +256,24 @@ const stmts = {
246
256
  getById: db.prepare(
247
257
  'SELECT * FROM memories WHERE id = ? AND valid_until IS NULL'
248
258
  ),
259
+ getByIdNs: db.prepare(
260
+ "SELECT * FROM memories WHERE id = ? AND (namespace = ? OR namespace = 'shared') AND valid_until IS NULL"
261
+ ),
249
262
  getAnyById: db.prepare(
250
263
  'SELECT * FROM memories WHERE id = ?'
251
264
  ),
252
265
  getRecent: db.prepare(
253
266
  'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY created_at DESC LIMIT ?'
254
267
  ),
268
+ getRecentNs: db.prepare(
269
+ "SELECT * FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL ORDER BY created_at DESC LIMIT ?"
270
+ ),
255
271
  getImportant: db.prepare(
256
272
  'SELECT * FROM memories WHERE valid_until IS NULL ORDER BY importance_score DESC LIMIT ?'
257
273
  ),
274
+ getImportantNs: db.prepare(
275
+ "SELECT * FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL ORDER BY importance_score DESC LIMIT ?"
276
+ ),
258
277
  getProvenance: db.prepare(
259
278
  'SELECT * FROM provenance WHERE memory_id = ?'
260
279
  ),
@@ -353,6 +372,9 @@ const stmts = {
353
372
  findMemoryByContent: db.prepare(
354
373
  'SELECT id FROM memories WHERE content = ? AND valid_until IS NULL LIMIT 1'
355
374
  ),
375
+ findMemoryByContentNs: db.prepare(
376
+ "SELECT id FROM memories WHERE content = ? AND (namespace = ? OR namespace = 'shared') AND valid_until IS NULL LIMIT 1"
377
+ ),
356
378
 
357
379
  // -- Hash-prefix lookup for git dedup (Bug 1 fix) --
358
380
  findMemoryByHashPrefix: db.prepare(
@@ -363,6 +385,14 @@ const stmts = {
363
385
  getActiveMemoryCount: db.prepare(
364
386
  'SELECT COUNT(*) as count FROM memories WHERE valid_until IS NULL'
365
387
  ),
388
+ getActiveMemoryCountNs: db.prepare(
389
+ "SELECT COUNT(*) as count FROM memories WHERE (namespace = ? OR namespace = 'shared') AND valid_until IS NULL"
390
+ ),
391
+
392
+ // -- Namespace stats --
393
+ getNamespaceStats: db.prepare(
394
+ 'SELECT namespace, COUNT(*) as count FROM memories WHERE valid_until IS NULL GROUP BY namespace ORDER BY count DESC'
395
+ ),
366
396
 
367
397
  // -- Memory History Chain (Feature 6: prepared statements) --
368
398
  getContradictionAncestors: db.prepare(
@@ -380,10 +410,14 @@ const stmts = {
380
410
 
381
411
  /**
382
412
  * Insert a new memory into the memories table and log its provenance.
413
+ * @param {string} content - Memory content
414
+ * @param {number} importance - Importance score (0-1)
415
+ * @param {Object} provenanceInfo - Provenance metadata
416
+ * @param {string} namespace - Namespace for agent isolation (default: 'shared')
383
417
  * @returns {number} The new memory's ID
384
418
  */
385
- export function insertMemory(content, importance = 1.0, provenanceInfo = null) {
386
- const result = stmts.insertMemory.run(content, importance);
419
+ export function insertMemory(content, importance = 1.0, provenanceInfo = null, namespace = 'shared') {
420
+ const result = stmts.insertMemory.run(content, importance, namespace || 'shared');
387
421
  const id = Number(result.lastInsertRowid);
388
422
 
389
423
  // Provenance Info handling
@@ -412,13 +446,16 @@ export function insertVector(id, embedding) {
412
446
 
413
447
  /**
414
448
  * Get a memory by ID. Boosts its importance on access.
449
+ * @param {number} id - Memory ID
450
+ * @param {string|null} namespace - Namespace filter (null = no filter)
415
451
  * @returns {object|null} The memory row, or null if not found
416
452
  */
417
- export function getMemory(id) {
418
- const memory = stmts.getById.get(id);
453
+ export function getMemory(id, namespace = null) {
454
+ const memory = namespace
455
+ ? stmts.getByIdNs.get(id, namespace)
456
+ : stmts.getById.get(id);
419
457
  if (memory) {
420
458
  boostMemory(id);
421
- // Fetch and link provenance info
422
459
  const prov = getProvenance(id);
423
460
  memory.provenance = prov;
424
461
  }
@@ -439,10 +476,14 @@ export function getAnyMemoryById(id) {
439
476
 
440
477
  /**
441
478
  * Get a memory by ID WITHOUT boosting. Used internally for search results.
479
+ * @param {number} id - Memory ID
480
+ * @param {string|null} namespace - Namespace filter (null = no filter)
442
481
  * @returns {object|null} The memory row, or null if not found
443
482
  */
444
- export function getMemoryById(id) {
445
- const memory = stmts.getById.get(id);
483
+ export function getMemoryById(id, namespace = null) {
484
+ const memory = namespace
485
+ ? stmts.getByIdNs.get(id, namespace)
486
+ : stmts.getById.get(id);
446
487
  if (memory) {
447
488
  memory.provenance = getProvenance(id);
448
489
  }
@@ -480,9 +521,13 @@ export function deleteMemory(id) {
480
521
 
481
522
  /**
482
523
  * Get the N most recently created memories.
524
+ * @param {number} limit - Max results
525
+ * @param {string|null} namespace - Namespace filter (null = all)
483
526
  */
484
- export function getRecentMemories(limit = 10) {
485
- const rows = stmts.getRecent.all(limit);
527
+ export function getRecentMemories(limit = 10, namespace = null) {
528
+ const rows = namespace
529
+ ? stmts.getRecentNs.all(namespace, limit)
530
+ : stmts.getRecent.all(limit);
486
531
  rows.forEach(r => {
487
532
  r.provenance = getProvenance(r.id);
488
533
  });
@@ -491,9 +536,13 @@ export function getRecentMemories(limit = 10) {
491
536
 
492
537
  /**
493
538
  * Get the N most important memories (by importance_score).
539
+ * @param {number} limit - Max results
540
+ * @param {string|null} namespace - Namespace filter (null = all)
494
541
  */
495
- export function getImportantMemories(limit = 10) {
496
- const rows = stmts.getImportant.all(limit);
542
+ export function getImportantMemories(limit = 10, namespace = null) {
543
+ const rows = namespace
544
+ ? stmts.getImportantNs.all(namespace, limit)
545
+ : stmts.getImportant.all(limit);
497
546
  rows.forEach(r => {
498
547
  r.provenance = getProvenance(r.id);
499
548
  });
@@ -620,9 +669,13 @@ export function getMemoriesByEntity(entityId) {
620
669
  * Check if a memory with exact content already exists.
621
670
  * Used for deduplication.
622
671
  * @param {string} content - Exact content to match
672
+ * @param {string|null} namespace - Namespace filter (null = global dedup)
623
673
  * @returns {boolean}
624
674
  */
625
- export function memoryExists(content) {
675
+ export function memoryExists(content, namespace = null) {
676
+ if (namespace) {
677
+ return stmts.findMemoryByContentNs.get(content, namespace) !== undefined;
678
+ }
626
679
  return stmts.findMemoryByContent.get(content) !== undefined;
627
680
  }
628
681
 
@@ -638,12 +691,24 @@ export function memoryExistsByHashPrefix(pattern) {
638
691
 
639
692
  /**
640
693
  * Get count of active (non-archived) memories.
694
+ * @param {string|null} namespace - Namespace filter (null = all)
641
695
  * @returns {number}
642
696
  */
643
- export function getActiveMemoryCount() {
697
+ export function getActiveMemoryCount(namespace = null) {
698
+ if (namespace) {
699
+ return stmts.getActiveMemoryCountNs.get(namespace).count;
700
+ }
644
701
  return stmts.getActiveMemoryCount.get().count;
645
702
  }
646
703
 
704
+ /**
705
+ * Get namespace breakdown stats.
706
+ * @returns {Array<{namespace: string, count: number}>}
707
+ */
708
+ export function getNamespaceStats() {
709
+ return stmts.getNamespaceStats.all();
710
+ }
711
+
647
712
  // ============================================================
648
713
  // DEDUPLICATION BY EXACT CONTENT
649
714
  // ============================================================
@@ -651,10 +716,13 @@ export function getActiveMemoryCount() {
651
716
  /**
652
717
  * Find memory by exact content.
653
718
  * @param {string} content
719
+ * @param {string|null} namespace - Namespace filter (null = global)
654
720
  * @returns {object|null} The memory row, or null if not found
655
721
  */
656
- export function getMemoryByContent(content) {
657
- const row = stmts.findMemoryByContent.get(content);
722
+ export function getMemoryByContent(content, namespace = null) {
723
+ const row = namespace
724
+ ? stmts.findMemoryByContentNs.get(content, namespace)
725
+ : stmts.findMemoryByContent.get(content);
658
726
  return row ? getMemoryById(row.id) : null;
659
727
  }
660
728