superlocalmemory 3.0.18 → 3.0.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.0.18",
3
+ "version": "3.0.20",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.0.18"
3
+ version = "3.0.20"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -27,6 +27,8 @@ def dispatch(args: Namespace) -> None:
27
27
  "remember": cmd_remember,
28
28
  "recall": cmd_recall,
29
29
  "forget": cmd_forget,
30
+ "delete": cmd_delete,
31
+ "update": cmd_update,
30
32
  "status": cmd_status,
31
33
  "health": cmd_health,
32
34
  "trace": cmd_trace,
@@ -199,6 +201,72 @@ def cmd_forget(args: Namespace) -> None:
199
201
  print("Cancelled.")
200
202
 
201
203
 
204
+ def cmd_delete(args: Namespace) -> None:
205
+ """Delete a specific memory by exact fact ID."""
206
+ from superlocalmemory.core.config import SLMConfig
207
+ from superlocalmemory.core.engine import MemoryEngine
208
+
209
+ config = SLMConfig.load()
210
+ engine = MemoryEngine(config)
211
+ engine.initialize()
212
+
213
+ fact_id = args.fact_id.strip()
214
+ # Look up the memory first so user can confirm
215
+ rows = engine._db.execute(
216
+ "SELECT content FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
217
+ (fact_id, engine.profile_id),
218
+ )
219
+ if not rows:
220
+ print(f"Memory not found: {fact_id}")
221
+ return
222
+
223
+ content_preview = dict(rows[0]).get("content", "")[:120]
224
+ print(f"Memory: {content_preview}")
225
+
226
+ if not getattr(args, "yes", False):
227
+ confirm = input("Delete this memory? [y/N] ").strip().lower()
228
+ if confirm not in ("y", "yes"):
229
+ print("Cancelled.")
230
+ return
231
+
232
+ engine._db.delete_fact(fact_id)
233
+ print(f"Deleted: {fact_id}")
234
+
235
+
236
+ def cmd_update(args: Namespace) -> None:
237
+ """Update the content of a specific memory by exact fact ID."""
238
+ from superlocalmemory.core.config import SLMConfig
239
+ from superlocalmemory.core.engine import MemoryEngine
240
+
241
+ config = SLMConfig.load()
242
+ engine = MemoryEngine(config)
243
+ engine.initialize()
244
+
245
+ fact_id = args.fact_id.strip()
246
+ new_content = args.content.strip()
247
+ if not new_content:
248
+ print("Error: content cannot be empty")
249
+ return
250
+
251
+ rows = engine._db.execute(
252
+ "SELECT content FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
253
+ (fact_id, engine.profile_id),
254
+ )
255
+ if not rows:
256
+ print(f"Memory not found: {fact_id}")
257
+ return
258
+
259
+ old_content = dict(rows[0]).get("content", "")
260
+ print(f"Old: {old_content[:100]}")
261
+ print(f"New: {new_content[:100]}")
262
+
263
+ engine._db.execute(
264
+ "UPDATE atomic_facts SET content = ? WHERE fact_id = ?",
265
+ (new_content, fact_id),
266
+ )
267
+ print(f"Updated: {fact_id}")
268
+
269
+
202
270
  def cmd_status(_args: Namespace) -> None:
203
271
  """Show system status."""
204
272
  from superlocalmemory.core.config import SLMConfig
@@ -119,10 +119,18 @@ def main() -> None:
119
119
  recall_p.add_argument("query", help="Search query")
120
120
  recall_p.add_argument("--limit", type=int, default=10, help="Max results (default 10)")
121
121
 
122
- forget_p = sub.add_parser("forget", help="Delete memories matching a query")
122
+ forget_p = sub.add_parser("forget", help="Delete memories matching a query (fuzzy)")
123
123
  forget_p.add_argument("query", help="Query to match for deletion")
124
124
 
125
- list_p = sub.add_parser("list", help="List recent memories chronologically")
125
+ delete_p = sub.add_parser("delete", help="Delete a specific memory by ID (precise)")
126
+ delete_p.add_argument("fact_id", help="Exact fact ID to delete")
127
+ delete_p.add_argument("--yes", "-y", action="store_true", help="Skip confirmation")
128
+
129
+ update_p = sub.add_parser("update", help="Edit the content of a specific memory by ID")
130
+ update_p.add_argument("fact_id", help="Exact fact ID to update")
131
+ update_p.add_argument("content", help="New content for the memory")
132
+
133
+ list_p = sub.add_parser("list", help="List recent memories chronologically (shows IDs for delete/update)")
126
134
  list_p.add_argument(
127
135
  "--limit", "-n", type=int, default=20, help="Number of entries (default 20)",
128
136
  )
@@ -353,6 +353,7 @@ class SLMConfig:
353
353
  provider=llm_provider or "ollama",
354
354
  model=llm_model or "phi3:mini",
355
355
  api_base=llm_api_base or "http://localhost:11434",
356
+ api_key=llm_api_key or "",
356
357
  ),
357
358
  retrieval=RetrievalConfig(use_cross_encoder=True),
358
359
  )
@@ -78,6 +78,25 @@ def _handle_store(content: str, metadata: dict) -> dict:
78
78
  engine = _get_engine()
79
79
  session_id = metadata.pop("session_id", "")
80
80
  fact_ids = engine.store(content, session_id=session_id, metadata=metadata)
81
+
82
+ # Generate and persist summary immediately after store (Mode A heuristic, B/C LLM)
83
+ if fact_ids:
84
+ try:
85
+ from superlocalmemory.core.summarizer import Summarizer
86
+ summarizer = Summarizer(engine._config)
87
+ summary = summarizer.summarize_cluster([{"content": content}])
88
+ if summary:
89
+ # Get the memory_id from the first stored fact
90
+ rows = engine._db.execute(
91
+ "SELECT memory_id FROM atomic_facts WHERE fact_id = ? LIMIT 1",
92
+ (fact_ids[0],),
93
+ )
94
+ if rows:
95
+ memory_id = dict(rows[0])["memory_id"]
96
+ engine._db.update_memory_summary(memory_id, summary)
97
+ except Exception:
98
+ pass # Summary is non-critical
99
+
81
100
  return {"ok": True, "fact_ids": fact_ids, "count": len(fact_ids)}
82
101
 
83
102
 
@@ -107,6 +126,49 @@ def _handle_get_memory_facts(memory_id: str) -> dict:
107
126
  }
108
127
 
109
128
 
129
+ def _handle_delete_memory(fact_id: str, agent_id: str = "system") -> dict:
130
+ """Delete a specific atomic fact by ID with audit logging."""
131
+ engine = _get_engine()
132
+ pid = engine.profile_id
133
+ rows = engine._db.execute(
134
+ "SELECT content FROM atomic_facts WHERE fact_id = ? AND profile_id = ? LIMIT 1",
135
+ (fact_id, pid),
136
+ )
137
+ if not rows:
138
+ return {"ok": False, "error": f"Memory {fact_id} not found"}
139
+ content_preview = dict(rows[0]).get("content", "")[:80]
140
+ engine._db.delete_fact(fact_id)
141
+ # Audit log
142
+ import logging as _logging
143
+ _logging.getLogger("superlocalmemory.audit").info(
144
+ "DELETE fact_id=%s by agent=%s content=%s", fact_id[:16], agent_id, content_preview,
145
+ )
146
+ return {"ok": True, "deleted": fact_id, "content_preview": content_preview}
147
+
148
+
149
+ def _handle_update_memory(fact_id: str, content: str, agent_id: str = "system") -> dict:
150
+ """Update content of a specific atomic fact with audit logging."""
151
+ engine = _get_engine()
152
+ pid = engine.profile_id
153
+ rows = engine._db.execute(
154
+ "SELECT content FROM atomic_facts WHERE fact_id = ? AND profile_id = ? LIMIT 1",
155
+ (fact_id, pid),
156
+ )
157
+ if not rows:
158
+ return {"ok": False, "error": f"Memory {fact_id} not found"}
159
+ old_content = dict(rows[0]).get("content", "")[:80]
160
+ engine._db.execute(
161
+ "UPDATE atomic_facts SET content = ? WHERE fact_id = ?",
162
+ (content, fact_id),
163
+ )
164
+ import logging as _logging
165
+ _logging.getLogger("superlocalmemory.audit").info(
166
+ "UPDATE fact_id=%s by agent=%s old=%s new=%s",
167
+ fact_id[:16], agent_id, old_content, content[:80],
168
+ )
169
+ return {"ok": True, "fact_id": fact_id, "content": content}
170
+
171
+
110
172
  def _handle_summarize(texts: list[str], mode: str) -> dict:
111
173
  """Generate summary using heuristic (A) or LLM (B/C)."""
112
174
  from superlocalmemory.core.summarizer import Summarizer
@@ -167,6 +229,18 @@ def _worker_main() -> None:
167
229
  elif cmd == "store":
168
230
  result = _handle_store(req.get("content", ""), req.get("metadata", {}))
169
231
  _respond(result)
232
+ elif cmd == "delete_memory":
233
+ result = _handle_delete_memory(
234
+ req.get("fact_id", ""), req.get("agent_id", "system"),
235
+ )
236
+ _respond(result)
237
+ elif cmd == "update_memory":
238
+ result = _handle_update_memory(
239
+ req.get("fact_id", ""),
240
+ req.get("content", ""),
241
+ req.get("agent_id", "system"),
242
+ )
243
+ _respond(result)
170
244
  elif cmd == "get_memory_facts":
171
245
  result = _handle_get_memory_facts(req.get("memory_id", ""))
172
246
  _respond(result)
@@ -73,6 +73,14 @@ class WorkerPool:
73
73
  "metadata": metadata or {},
74
74
  })
75
75
 
76
+ def delete_memory(self, fact_id: str, agent_id: str = "system") -> dict:
77
+ """Delete a specific memory by fact_id. Logged for audit."""
78
+ return self._send({"cmd": "delete_memory", "fact_id": fact_id, "agent_id": agent_id})
79
+
80
+ def update_memory(self, fact_id: str, content: str, agent_id: str = "system") -> dict:
81
+ """Update content of a specific memory. Logged for audit."""
82
+ return self._send({"cmd": "update_memory", "fact_id": fact_id, "content": content, "agent_id": agent_id})
83
+
76
84
  def get_memory_facts(self, memory_id: str) -> dict:
77
85
  """Get original memory text + child atomic facts."""
78
86
  return self._send({"cmd": "get_memory_facts", "memory_id": memory_id})
@@ -281,6 +281,67 @@ def register_core_tools(server, get_engine: Callable) -> None:
281
281
  logger.exception("correct_pattern failed")
282
282
  return {"success": False, "error": str(exc)}
283
283
 
284
+ @server.tool()
285
+ async def delete_memory(fact_id: str, agent_id: str = "mcp_client") -> dict:
286
+ """Delete a specific memory by exact fact ID.
287
+
288
+ Security note: This is a destructive operation. All deletions are
289
+ logged with the calling agent_id for audit trail. Use get_status or
290
+ list_recent to find fact_ids before deleting.
291
+
292
+ Args:
293
+ fact_id: Exact fact ID to delete (from recall or list_recent results).
294
+ agent_id: Identifier of the calling agent (logged for audit).
295
+ """
296
+ try:
297
+ from superlocalmemory.core.worker_pool import WorkerPool
298
+ pool = WorkerPool.shared()
299
+ result = pool._send({
300
+ "cmd": "delete_memory",
301
+ "fact_id": fact_id,
302
+ "agent_id": agent_id,
303
+ })
304
+ if result.get("ok"):
305
+ logger.info("Memory deleted: %s by agent: %s", fact_id[:16], agent_id)
306
+ return {"success": True, "deleted": fact_id, "agent_id": agent_id}
307
+ return {"success": False, "error": result.get("error", "Delete failed")}
308
+ except Exception as exc:
309
+ logger.exception("delete_memory failed")
310
+ return {"success": False, "error": str(exc)}
311
+
312
+ @server.tool()
313
+ async def update_memory(
314
+ fact_id: str, content: str, agent_id: str = "mcp_client",
315
+ ) -> dict:
316
+ """Update the content of a specific memory by exact fact ID.
317
+
318
+ Security note: All updates are logged with the calling agent_id.
319
+ The fact_id must belong to the active profile.
320
+
321
+ Args:
322
+ fact_id: Exact fact ID to update.
323
+ content: New content for the memory (cannot be empty).
324
+ agent_id: Identifier of the calling agent (logged for audit).
325
+ """
326
+ try:
327
+ if not content or not content.strip():
328
+ return {"success": False, "error": "content cannot be empty"}
329
+ from superlocalmemory.core.worker_pool import WorkerPool
330
+ pool = WorkerPool.shared()
331
+ result = pool._send({
332
+ "cmd": "update_memory",
333
+ "fact_id": fact_id,
334
+ "content": content.strip(),
335
+ "agent_id": agent_id,
336
+ })
337
+ if result.get("ok"):
338
+ logger.info("Memory updated: %s by agent: %s", fact_id[:16], agent_id)
339
+ return {"success": True, "fact_id": fact_id, "content": content.strip()}
340
+ return {"success": False, "error": result.get("error", "Update failed")}
341
+ except Exception as exc:
342
+ logger.exception("update_memory failed")
343
+ return {"success": False, "error": str(exc)}
344
+
284
345
  @server.tool()
285
346
  async def get_attribution() -> dict:
286
347
  """Get system attribution: author, version, license, and provenance metadata."""
@@ -455,3 +455,61 @@ async def get_memory_facts(request: Request, memory_id: str):
455
455
  raise
456
456
  except Exception as e:
457
457
  raise HTTPException(status_code=500, detail=f"Error: {str(e)}")
458
+
459
+
460
+ @router.delete("/api/memories/{fact_id}")
461
+ async def delete_memory(request: Request, fact_id: str):
462
+ """Delete a specific memory (atomic fact) by ID."""
463
+ try:
464
+ conn = get_db_connection()
465
+ conn.row_factory = dict_factory
466
+ cursor = conn.cursor()
467
+ active_profile = get_active_profile()
468
+ # Verify it exists and belongs to this profile
469
+ cursor.execute(
470
+ "SELECT fact_id FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
471
+ (fact_id, active_profile),
472
+ )
473
+ if not cursor.fetchone():
474
+ conn.close()
475
+ raise HTTPException(status_code=404, detail="Memory not found")
476
+ cursor.execute("DELETE FROM atomic_facts WHERE fact_id = ?", (fact_id,))
477
+ conn.commit()
478
+ conn.close()
479
+ return {"success": True, "deleted": fact_id}
480
+ except HTTPException:
481
+ raise
482
+ except Exception as e:
483
+ raise HTTPException(status_code=500, detail=f"Delete error: {str(e)}")
484
+
485
+
486
+ @router.patch("/api/memories/{fact_id}")
487
+ async def edit_memory(request: Request, fact_id: str):
488
+ """Edit the content of a specific memory (atomic fact)."""
489
+ try:
490
+ body = await request.json()
491
+ new_content = (body.get("content") or "").strip()
492
+ if not new_content:
493
+ raise HTTPException(status_code=400, detail="content is required")
494
+ conn = get_db_connection()
495
+ conn.row_factory = dict_factory
496
+ cursor = conn.cursor()
497
+ active_profile = get_active_profile()
498
+ cursor.execute(
499
+ "SELECT fact_id FROM atomic_facts WHERE fact_id = ? AND profile_id = ?",
500
+ (fact_id, active_profile),
501
+ )
502
+ if not cursor.fetchone():
503
+ conn.close()
504
+ raise HTTPException(status_code=404, detail="Memory not found")
505
+ cursor.execute(
506
+ "UPDATE atomic_facts SET content = ? WHERE fact_id = ?",
507
+ (new_content, fact_id),
508
+ )
509
+ conn.commit()
510
+ conn.close()
511
+ return {"success": True, "fact_id": fact_id, "content": new_content}
512
+ except HTTPException:
513
+ raise
514
+ except Exception as e:
515
+ raise HTTPException(status_code=500, detail=f"Edit error: {str(e)}")
@@ -128,6 +128,32 @@ class DatabaseManager:
128
128
  )
129
129
  return record.memory_id
130
130
 
131
+ def update_memory_summary(self, memory_id: str, summary: str) -> None:
132
+ """Store a generated summary for a memory record."""
133
+ try:
134
+ self.execute(
135
+ "UPDATE memories SET metadata_json = json_set("
136
+ " COALESCE(metadata_json, '{}'), '$.summary', ?"
137
+ ") WHERE memory_id = ?",
138
+ (summary, memory_id),
139
+ )
140
+ except Exception:
141
+ pass # Non-critical — summary is enhancement only
142
+
143
+ def get_memory_summary(self, memory_id: str) -> str:
144
+ """Retrieve stored summary for a memory, or empty string."""
145
+ try:
146
+ rows = self.execute(
147
+ "SELECT json_extract(metadata_json, '$.summary') as s "
148
+ "FROM memories WHERE memory_id = ?",
149
+ (memory_id,),
150
+ )
151
+ if rows:
152
+ return dict(rows[0]).get("s") or ""
153
+ except Exception:
154
+ pass
155
+ return ""
156
+
131
157
  def store_fact(self, fact: AtomicFact) -> str:
132
158
  """Persist an atomic fact. Returns fact_id."""
133
159
  self.execute(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.0.18
3
+ Version: 3.0.20
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: MIT
package/ui/js/modal.js CHANGED
@@ -176,6 +176,63 @@ function openMemoryDetail(mem, source) {
176
176
  actionsDiv.appendChild(filterBtn);
177
177
  }
178
178
 
179
+ // Edit button — always available
180
+ var editBtn = document.createElement('button');
181
+ editBtn.className = 'btn btn-outline-warning btn-sm';
182
+ editBtn.innerHTML = '<i class="bi bi-pencil"></i> Edit';
183
+ editBtn.onclick = function() {
184
+ var currentText = contentDiv.textContent;
185
+ var textarea = document.createElement('textarea');
186
+ textarea.className = 'form-control mb-2';
187
+ textarea.rows = 4;
188
+ textarea.value = currentText;
189
+ contentDiv.textContent = '';
190
+ contentDiv.appendChild(textarea);
191
+ var saveBtn = document.createElement('button');
192
+ saveBtn.className = 'btn btn-sm btn-success me-1';
193
+ saveBtn.textContent = 'Save';
194
+ saveBtn.onclick = function() {
195
+ var newContent = textarea.value.trim();
196
+ if (!newContent) return;
197
+ fetch('/api/memories/' + encodeURIComponent(mem.id), {
198
+ method: 'PATCH',
199
+ headers: {'Content-Type': 'application/json'},
200
+ body: JSON.stringify({content: newContent})
201
+ }).then(function(r) { return r.json(); }).then(function(d) {
202
+ if (d.success) {
203
+ contentDiv.textContent = newContent;
204
+ mem.content = newContent;
205
+ if (typeof showToast === 'function') showToast('Memory updated');
206
+ }
207
+ });
208
+ };
209
+ var cancelBtn = document.createElement('button');
210
+ cancelBtn.className = 'btn btn-sm btn-secondary';
211
+ cancelBtn.textContent = 'Cancel';
212
+ cancelBtn.onclick = function() { contentDiv.textContent = currentText; };
213
+ contentDiv.appendChild(saveBtn);
214
+ contentDiv.appendChild(cancelBtn);
215
+ };
216
+ actionsDiv.appendChild(editBtn);
217
+
218
+ // Delete button — always available
219
+ var deleteBtn = document.createElement('button');
220
+ deleteBtn.className = 'btn btn-outline-danger btn-sm';
221
+ deleteBtn.innerHTML = '<i class="bi bi-trash"></i> Delete';
222
+ deleteBtn.onclick = function() {
223
+ if (!confirm('Delete this memory? This cannot be undone.')) return;
224
+ fetch('/api/memories/' + encodeURIComponent(mem.id), {method: 'DELETE'})
225
+ .then(function(r) { return r.json(); })
226
+ .then(function(d) {
227
+ if (d.success) {
228
+ modal.hide();
229
+ if (typeof showToast === 'function') showToast('Memory deleted');
230
+ if (typeof loadMemories === 'function') setTimeout(loadMemories, 300);
231
+ }
232
+ });
233
+ };
234
+ actionsDiv.appendChild(deleteBtn);
235
+
179
236
  body.appendChild(actionsDiv);
180
237
  }
181
238