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.
- package/bin/extract-worker.js +387 -0
- package/bin/extract.js +185 -0
- package/bin/init.js +2 -1
- package/bin/setup.js +9 -4
- package/hooks/persyst-hook.js +195 -10
- package/index.js +7 -0
- package/package.json +7 -3
- package/src/database.js +84 -16
- package/src/extractor-heuristic.js +250 -0
- package/src/search.js +18 -10
- package/src/server.js +1 -1
- package/src/tools.js +40 -26
package/hooks/persyst-hook.js
CHANGED
|
@@ -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,
|
|
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
|
|
62
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|