superlocalmemory 3.0.28 → 3.0.30

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/CHANGELOG.md CHANGED
@@ -16,6 +16,15 @@ SuperLocalMemory V3 - Intelligent local memory system for AI coding assistants.
16
16
 
17
17
  ---
18
18
 
19
+ ## [3.0.30] - 2026-03-21
20
+
21
+ ### Fixed
22
+ - Profile switching and display uses correct identifiers
23
+ - Profile sync across CLI, Dashboard, and MCP — all entry points now see the same profiles
24
+ - Profile switching now persists correctly across restarts
25
+
26
+ ---
27
+
19
28
  ## [2.8.6] - 2026-03-06
20
29
 
21
30
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.0.28",
3
+ "version": "3.0.30",
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.28"
3
+ version = "3.0.30"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -23,6 +23,9 @@ dependencies = [
23
23
  "rank-bm25>=0.2.2",
24
24
  "vadersentiment>=3.3.2",
25
25
  "einops>=0.8.2",
26
+ "fastapi[all]>=0.135.1",
27
+ "uvicorn>=0.42.0",
28
+ "websockets>=16.0",
26
29
  ]
27
30
 
28
31
  [project.optional-dependencies]
@@ -699,10 +699,17 @@ def cmd_dashboard(args: Namespace) -> None:
699
699
 
700
700
 
701
701
  def cmd_profile(args: Namespace) -> None:
702
- """Profile management (list, switch, create)."""
702
+ """Profile management (list, switch, create).
703
+
704
+ Writes to BOTH SQLite and profiles.json so CLI, Dashboard, and
705
+ MCP all see the same profiles.
706
+ """
703
707
  from superlocalmemory.core.config import SLMConfig
704
708
  from superlocalmemory.storage.database import DatabaseManager
705
709
  from superlocalmemory.storage import schema
710
+ from superlocalmemory.server.routes.helpers import (
711
+ ensure_profile_in_json, set_active_profile_everywhere,
712
+ )
706
713
 
707
714
  config = SLMConfig.load()
708
715
  db = DatabaseManager(config.db_path)
@@ -721,6 +728,7 @@ def cmd_profile(args: Namespace) -> None:
721
728
  {"command": "slm profile switch <name> --json", "description": "Switch profile"},
722
729
  ])
723
730
  elif args.action == "switch":
731
+ set_active_profile_everywhere(args.name)
724
732
  config.active_profile = args.name
725
733
  config.save()
726
734
  json_print("profile", data={"action": "switched", "profile": args.name})
@@ -729,6 +737,7 @@ def cmd_profile(args: Namespace) -> None:
729
737
  "INSERT OR IGNORE INTO profiles (profile_id, name) VALUES (?, ?)",
730
738
  (args.name, args.name),
731
739
  )
740
+ ensure_profile_in_json(args.name)
732
741
  json_print("profile", data={"action": "created", "profile": args.name},
733
742
  next_actions=[
734
743
  {"command": f"slm profile switch {args.name} --json",
@@ -743,13 +752,14 @@ def cmd_profile(args: Namespace) -> None:
743
752
  d = dict(r)
744
753
  print(f" - {d['profile_id']}: {d.get('name', '')}")
745
754
  elif args.action == "switch":
755
+ set_active_profile_everywhere(args.name)
746
756
  config.active_profile = args.name
747
757
  config.save()
748
758
  print(f"Switched to profile: {args.name}")
749
759
  elif args.action == "create":
750
- from superlocalmemory.storage.models import _new_id
751
760
  db.execute(
752
761
  "INSERT OR IGNORE INTO profiles (profile_id, name) VALUES (?, ?)",
753
762
  (args.name, args.name),
754
763
  )
764
+ ensure_profile_in_json(args.name)
755
765
  print(f"Created profile: {args.name}")
@@ -201,6 +201,14 @@ def register_core_tools(server, get_engine: Callable) -> None:
201
201
  engine = get_engine()
202
202
  old = engine.profile_id
203
203
  engine.profile_id = profile_id
204
+
205
+ # Persist to both config stores so CLI and Dashboard stay in sync
206
+ from superlocalmemory.server.routes.helpers import (
207
+ ensure_profile_in_db, set_active_profile_everywhere,
208
+ )
209
+ ensure_profile_in_db(profile_id)
210
+ set_active_profile_everywhere(profile_id)
211
+
204
212
  return {
205
213
  "success": True,
206
214
  "previous_profile": old,
@@ -78,6 +78,169 @@ def validate_profile_name(name: str) -> bool:
78
78
  return bool(re.match(r'^[a-zA-Z0-9_-]+$', name))
79
79
 
80
80
 
81
+ # ============================================================================
82
+ # Profile Sync — SQLite as single source of truth
83
+ # ============================================================================
84
+
85
+
86
+ def ensure_profile_in_db(name: str, description: str = "") -> None:
87
+ """Ensure a profile row exists in SQLite (idempotent)."""
88
+ if not DB_PATH.exists():
89
+ return
90
+ conn = sqlite3.connect(str(DB_PATH))
91
+ try:
92
+ conn.execute("PRAGMA foreign_keys=ON")
93
+ conn.execute(
94
+ "INSERT OR IGNORE INTO profiles (profile_id, name, description) "
95
+ "VALUES (?, ?, ?)",
96
+ (name, name, description or f"Memory profile: {name}"),
97
+ )
98
+ conn.commit()
99
+ finally:
100
+ conn.close()
101
+
102
+
103
+ def ensure_profile_in_json(name: str, description: str = "") -> None:
104
+ """Ensure a profile entry exists in profiles.json (idempotent)."""
105
+ from datetime import datetime
106
+ config_file = MEMORY_DIR / "profiles.json"
107
+ config = _load_profiles_json()
108
+ profiles = config.get('profiles', {})
109
+ if name not in profiles:
110
+ profiles[name] = {
111
+ 'name': name,
112
+ 'description': description or f'Memory profile: {name}',
113
+ 'created_at': datetime.now().isoformat(),
114
+ 'last_used': None,
115
+ }
116
+ config['profiles'] = profiles
117
+ _save_profiles_json(config)
118
+
119
+
120
+ def sync_profiles() -> list[dict]:
121
+ """Reconcile SQLite and profiles.json. Returns merged profile list.
122
+
123
+ SQLite is the source of truth. Uses ``profile_id`` (not ``name``)
124
+ as the canonical key because profile_id is the PK referenced by
125
+ every FK in the database.
126
+ """
127
+ db_profiles = _get_db_profiles()
128
+ json_config = _load_profiles_json()
129
+ json_profiles = json_config.get('profiles', {})
130
+
131
+ # profile_id is the canonical key (PK in SQLite, FK target everywhere)
132
+ db_ids = {p['profile_id'] for p in db_profiles}
133
+ json_keys = set(json_profiles.keys())
134
+
135
+ changed = False
136
+
137
+ # JSON-only → add to SQLite (fixes Dashboard-created profiles)
138
+ for key in json_keys - db_ids:
139
+ ensure_profile_in_db(key, json_profiles[key].get('description', ''))
140
+
141
+ # SQLite-only → add to profiles.json (fixes CLI-created profiles)
142
+ for pid in db_ids - json_keys:
143
+ db_entry = next(p for p in db_profiles if p['profile_id'] == pid)
144
+ json_profiles[pid] = {
145
+ 'name': pid,
146
+ 'description': db_entry.get('description', ''),
147
+ 'created_at': db_entry.get('created_at', ''),
148
+ 'last_used': db_entry.get('last_used'),
149
+ }
150
+ changed = True
151
+
152
+ if changed:
153
+ json_config['profiles'] = json_profiles
154
+ _save_profiles_json(json_config)
155
+
156
+ # Return merged list from SQLite (now authoritative)
157
+ return _get_db_profiles()
158
+
159
+
160
+ def set_active_profile_everywhere(name: str) -> None:
161
+ """Persist the active profile to BOTH profiles.json and config.json."""
162
+ # profiles.json
163
+ config = _load_profiles_json()
164
+ config['active_profile'] = name
165
+ _save_profiles_json(config)
166
+
167
+ # config.json (read by Engine/MCP on startup)
168
+ config_path = MEMORY_DIR / "config.json"
169
+ cfg = {}
170
+ if config_path.exists():
171
+ try:
172
+ cfg = json.loads(config_path.read_text())
173
+ except (json.JSONDecodeError, IOError):
174
+ pass
175
+ cfg['active_profile'] = name
176
+ config_path.write_text(json.dumps(cfg, indent=2))
177
+
178
+
179
+ def delete_profile_from_db(name: str) -> None:
180
+ """Delete a profile row from SQLite. ON DELETE CASCADE handles child rows."""
181
+ if not DB_PATH.exists():
182
+ return
183
+ conn = sqlite3.connect(str(DB_PATH))
184
+ try:
185
+ conn.execute("PRAGMA foreign_keys=ON")
186
+ conn.execute("DELETE FROM profiles WHERE profile_id = ?", (name,))
187
+ conn.commit()
188
+ finally:
189
+ conn.close()
190
+
191
+
192
+ def _get_db_profiles() -> list[dict]:
193
+ """Read all profiles from SQLite."""
194
+ if not DB_PATH.exists():
195
+ return []
196
+ conn = sqlite3.connect(str(DB_PATH))
197
+ conn.row_factory = sqlite3.Row
198
+ try:
199
+ rows = conn.execute(
200
+ "SELECT profile_id, name, description, created_at, last_used "
201
+ "FROM profiles ORDER BY name"
202
+ ).fetchall()
203
+ return [dict(r) for r in rows]
204
+ except sqlite3.OperationalError:
205
+ return []
206
+ finally:
207
+ conn.close()
208
+
209
+
210
+ def _load_profiles_json() -> dict:
211
+ """Load profiles.json config (Dashboard dict format)."""
212
+ config_file = MEMORY_DIR / "profiles.json"
213
+ if config_file.exists():
214
+ try:
215
+ with open(config_file, 'r') as f:
216
+ data = json.load(f)
217
+ # Handle ProfileManager array format → convert to dict format
218
+ if isinstance(data.get('profiles'), list):
219
+ converted = {}
220
+ for p in data['profiles']:
221
+ n = p.get('name', '')
222
+ if n:
223
+ converted[n] = p
224
+ data['profiles'] = converted
225
+ if 'active' in data and 'active_profile' not in data:
226
+ data['active_profile'] = data.pop('active')
227
+ return data
228
+ except (json.JSONDecodeError, IOError):
229
+ pass
230
+ return {
231
+ 'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}},
232
+ 'active_profile': 'default',
233
+ }
234
+
235
+
236
+ def _save_profiles_json(config: dict) -> None:
237
+ """Save profiles.json config."""
238
+ MEMORY_DIR.mkdir(parents=True, exist_ok=True)
239
+ config_file = MEMORY_DIR / "profiles.json"
240
+ with open(config_file, 'w') as f:
241
+ json.dump(config, f, indent=2)
242
+
243
+
81
244
  # ============================================================================
82
245
  # Pydantic Models (shared across routes)
83
246
  # ============================================================================
@@ -6,16 +6,21 @@
6
6
 
7
7
  Routes: /api/profiles, /api/profiles/{name}/switch,
8
8
  /api/profiles/create, DELETE /api/profiles/{name}
9
+
10
+ SQLite is the single source of truth for profiles. profiles.json
11
+ is kept in sync as a cache for backward compatibility.
9
12
  """
10
- import json
11
13
  import logging
12
14
  from datetime import datetime
13
15
 
14
16
  from fastapi import APIRouter, HTTPException
15
17
 
16
18
  from .helpers import (
17
- get_db_connection, get_active_profile, validate_profile_name,
18
- ProfileSwitch, MEMORY_DIR, DB_PATH,
19
+ get_db_connection, validate_profile_name,
20
+ ProfileSwitch, DB_PATH,
21
+ sync_profiles, ensure_profile_in_db, ensure_profile_in_json,
22
+ set_active_profile_everywhere, delete_profile_from_db,
23
+ _load_profiles_json, _save_profiles_json,
19
24
  )
20
25
 
21
26
  logger = logging.getLogger("superlocalmemory.routes.profiles")
@@ -25,35 +30,11 @@ router = APIRouter()
25
30
  ws_manager = None
26
31
 
27
32
 
28
- def _load_profiles_config() -> dict:
29
- """Load profiles.json config."""
30
- config_file = MEMORY_DIR / "profiles.json"
31
- if config_file.exists():
32
- try:
33
- with open(config_file, 'r') as f:
34
- return json.load(f)
35
- except (json.JSONDecodeError, IOError):
36
- pass
37
- return {
38
- 'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}},
39
- 'active_profile': 'default',
40
- }
41
-
42
-
43
- def _save_profiles_config(config: dict) -> None:
44
- """Save profiles.json config."""
45
- MEMORY_DIR.mkdir(parents=True, exist_ok=True)
46
- config_file = MEMORY_DIR / "profiles.json"
47
- with open(config_file, 'w') as f:
48
- json.dump(config, f, indent=2)
49
-
50
-
51
33
  def _get_memory_count(profile: str) -> int:
52
- """Get memory count for a profile (V3 atomic_facts or V2 memories)."""
34
+ """Get memory count for a profile."""
53
35
  try:
54
36
  conn = get_db_connection()
55
37
  cursor = conn.cursor()
56
- # Try V3 table first
57
38
  try:
58
39
  cursor.execute(
59
40
  "SELECT COUNT(*) FROM atomic_facts WHERE profile_id = ?", (profile,),
@@ -72,21 +53,24 @@ def _get_memory_count(profile: str) -> int:
72
53
 
73
54
  @router.get("/api/profiles")
74
55
  async def list_profiles():
75
- """List available memory profiles."""
56
+ """List available memory profiles (synced from SQLite + profiles.json)."""
76
57
  try:
77
- config = _load_profiles_config()
78
- active = config.get('active_profile', 'default')
79
- profiles = []
58
+ merged = sync_profiles()
59
+ json_config = _load_profiles_json()
60
+ active = json_config.get('active_profile', 'default')
80
61
 
81
- for name, info in config.get('profiles', {}).items():
82
- count = _get_memory_count(name)
62
+ profiles = []
63
+ for p in merged:
64
+ # profile_id is the canonical key (PK, FK target, used by engine)
65
+ pid = p.get('profile_id', p.get('name', ''))
66
+ count = _get_memory_count(pid)
83
67
  profiles.append({
84
- "name": name,
85
- "description": info.get('description', ''),
68
+ "name": pid,
69
+ "description": p.get('description', ''),
86
70
  "memory_count": count,
87
- "created_at": info.get('created_at', ''),
88
- "last_used": info.get('last_used', ''),
89
- "is_active": name == active,
71
+ "created_at": p.get('created_at', ''),
72
+ "last_used": p.get('last_used', ''),
73
+ "is_active": pid == active,
90
74
  })
91
75
 
92
76
  return {
@@ -101,24 +85,29 @@ async def list_profiles():
101
85
 
102
86
  @router.post("/api/profiles/{name}/switch")
103
87
  async def switch_profile(name: str):
104
- """Switch active memory profile."""
88
+ """Switch active memory profile (persists to both config stores)."""
105
89
  try:
106
90
  if not validate_profile_name(name):
107
91
  raise HTTPException(status_code=400, detail="Invalid profile name.")
108
92
 
109
- config = _load_profiles_config()
93
+ merged = sync_profiles()
94
+ merged_ids = {p.get('profile_id', p.get('name', '')) for p in merged}
110
95
 
111
- if name not in config.get('profiles', {}):
112
- available = ', '.join(config.get('profiles', {}).keys())
96
+ if name not in merged_ids:
97
+ available = ', '.join(sorted(merged_ids))
113
98
  raise HTTPException(
114
99
  status_code=404,
115
100
  detail=f"Profile '{name}' not found. Available: {available}",
116
101
  )
117
102
 
118
- previous = config.get('active_profile', 'default')
119
- config['active_profile'] = name
120
- config['profiles'][name]['last_used'] = datetime.now().isoformat()
121
- _save_profiles_config(config)
103
+ previous = _load_profiles_json().get('active_profile', 'default')
104
+ set_active_profile_everywhere(name)
105
+
106
+ # Update last_used in profiles.json
107
+ json_config = _load_profiles_json()
108
+ if name in json_config.get('profiles', {}):
109
+ json_config['profiles'][name]['last_used'] = datetime.now().isoformat()
110
+ _save_profiles_json(json_config)
122
111
 
123
112
  count = _get_memory_count(name)
124
113
 
@@ -143,22 +132,22 @@ async def switch_profile(name: str):
143
132
 
144
133
  @router.post("/api/profiles/create")
145
134
  async def create_profile(body: ProfileSwitch):
146
- """Create a new memory profile."""
135
+ """Create a new memory profile (writes to BOTH SQLite and profiles.json)."""
147
136
  try:
148
137
  name = body.profile_name
149
138
  if not validate_profile_name(name):
150
139
  raise HTTPException(status_code=400, detail="Invalid profile name")
151
140
 
152
- config = _load_profiles_config()
153
-
154
- if name in config.get('profiles', {}):
141
+ # Check both stores for duplicates
142
+ merged = sync_profiles()
143
+ merged_ids = {p.get('profile_id', p.get('name', '')) for p in merged}
144
+ if name in merged_ids:
155
145
  raise HTTPException(status_code=409, detail=f"Profile '{name}' already exists")
156
146
 
157
- config['profiles'][name] = {
158
- 'name': name, 'description': f'Memory profile: {name}',
159
- 'created_at': datetime.now().isoformat(), 'last_used': None,
160
- }
161
- _save_profiles_config(config)
147
+ # Write to BOTH stores atomically
148
+ desc = f'Memory profile: {name}'
149
+ ensure_profile_in_db(name, desc)
150
+ ensure_profile_in_json(name, desc)
162
151
 
163
152
  return {"success": True, "profile": name, "message": f"Profile '{name}' created"}
164
153
 
@@ -175,16 +164,18 @@ async def delete_profile(name: str):
175
164
  if name == 'default':
176
165
  raise HTTPException(status_code=400, detail="Cannot delete 'default' profile")
177
166
 
178
- config = _load_profiles_config()
179
-
180
- if name not in config.get('profiles', {}):
167
+ merged = sync_profiles()
168
+ merged_ids = {p.get('profile_id', p.get('name', '')) for p in merged}
169
+ if name not in merged_ids:
181
170
  raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")
182
- if config.get('active_profile') == name:
171
+
172
+ json_config = _load_profiles_json()
173
+ if json_config.get('active_profile') == name:
183
174
  raise HTTPException(status_code=400, detail="Cannot delete active profile.")
184
175
 
176
+ # Move data to default before deleting (bypasses CASCADE)
185
177
  conn = get_db_connection()
186
178
  cursor = conn.cursor()
187
- # Move memories to default (try V3 first, then V2)
188
179
  moved = 0
189
180
  try:
190
181
  cursor.execute(
@@ -196,7 +187,7 @@ async def delete_profile(name: str):
196
187
  pass
197
188
  try:
198
189
  cursor.execute(
199
- "UPDATE memories SET profile = 'default' WHERE profile = ?",
190
+ "UPDATE memories SET profile_id = 'default' WHERE profile_id = ?",
200
191
  (name,),
201
192
  )
202
193
  moved += cursor.rowcount
@@ -205,8 +196,13 @@ async def delete_profile(name: str):
205
196
  conn.commit()
206
197
  conn.close()
207
198
 
208
- del config['profiles'][name]
209
- _save_profiles_config(config)
199
+ # Delete from BOTH stores
200
+ delete_profile_from_db(name)
201
+
202
+ profiles = json_config.get('profiles', {})
203
+ profiles.pop(name, None)
204
+ json_config['profiles'] = profiles
205
+ _save_profiles_json(json_config)
210
206
 
211
207
  return {
212
208
  "success": True,