superlocalmemory 3.0.28 → 3.0.29

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,14 @@ SuperLocalMemory V3 - Intelligent local memory system for AI coding assistants.
16
16
 
17
17
  ---
18
18
 
19
+ ## [3.0.29] - 2026-03-21
20
+
21
+ ### Fixed
22
+ - Profile sync across CLI, Dashboard, and MCP — all entry points now see the same profiles
23
+ - Profile switching now persists correctly across restarts
24
+
25
+ ---
26
+
19
27
  ## [2.8.6] - 2026-03-06
20
28
 
21
29
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.0.28",
3
+ "version": "3.0.29",
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.29"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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,168 @@ 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. Any profile in profiles.json
124
+ that's missing from SQLite is added to SQLite. Any profile in
125
+ SQLite that's missing from profiles.json is added to profiles.json.
126
+ """
127
+ db_profiles = _get_db_profiles()
128
+ json_config = _load_profiles_json()
129
+ json_profiles = json_config.get('profiles', {})
130
+
131
+ db_names = {p['name'] for p in db_profiles}
132
+ json_names = set(json_profiles.keys())
133
+
134
+ changed = False
135
+
136
+ # JSON-only → add to SQLite (fixes Dashboard-created profiles)
137
+ for name in json_names - db_names:
138
+ ensure_profile_in_db(name, json_profiles[name].get('description', ''))
139
+
140
+ # SQLite-only → add to profiles.json (fixes CLI-created profiles)
141
+ for name in db_names - json_names:
142
+ db_entry = next(p for p in db_profiles if p['name'] == name)
143
+ json_profiles[name] = {
144
+ 'name': name,
145
+ 'description': db_entry.get('description', ''),
146
+ 'created_at': db_entry.get('created_at', ''),
147
+ 'last_used': db_entry.get('last_used'),
148
+ }
149
+ changed = True
150
+
151
+ if changed:
152
+ json_config['profiles'] = json_profiles
153
+ _save_profiles_json(json_config)
154
+
155
+ # Return merged list from SQLite (now authoritative)
156
+ return _get_db_profiles()
157
+
158
+
159
+ def set_active_profile_everywhere(name: str) -> None:
160
+ """Persist the active profile to BOTH profiles.json and config.json."""
161
+ # profiles.json
162
+ config = _load_profiles_json()
163
+ config['active_profile'] = name
164
+ _save_profiles_json(config)
165
+
166
+ # config.json (read by Engine/MCP on startup)
167
+ config_path = MEMORY_DIR / "config.json"
168
+ cfg = {}
169
+ if config_path.exists():
170
+ try:
171
+ cfg = json.loads(config_path.read_text())
172
+ except (json.JSONDecodeError, IOError):
173
+ pass
174
+ cfg['active_profile'] = name
175
+ config_path.write_text(json.dumps(cfg, indent=2))
176
+
177
+
178
+ def delete_profile_from_db(name: str) -> None:
179
+ """Delete a profile row from SQLite. ON DELETE CASCADE handles child rows."""
180
+ if not DB_PATH.exists():
181
+ return
182
+ conn = sqlite3.connect(str(DB_PATH))
183
+ try:
184
+ conn.execute("PRAGMA foreign_keys=ON")
185
+ conn.execute("DELETE FROM profiles WHERE profile_id = ?", (name,))
186
+ conn.commit()
187
+ finally:
188
+ conn.close()
189
+
190
+
191
+ def _get_db_profiles() -> list[dict]:
192
+ """Read all profiles from SQLite."""
193
+ if not DB_PATH.exists():
194
+ return []
195
+ conn = sqlite3.connect(str(DB_PATH))
196
+ conn.row_factory = sqlite3.Row
197
+ try:
198
+ rows = conn.execute(
199
+ "SELECT profile_id, name, description, created_at, last_used "
200
+ "FROM profiles ORDER BY name"
201
+ ).fetchall()
202
+ return [dict(r) for r in rows]
203
+ except sqlite3.OperationalError:
204
+ return []
205
+ finally:
206
+ conn.close()
207
+
208
+
209
+ def _load_profiles_json() -> dict:
210
+ """Load profiles.json config (Dashboard dict format)."""
211
+ config_file = MEMORY_DIR / "profiles.json"
212
+ if config_file.exists():
213
+ try:
214
+ with open(config_file, 'r') as f:
215
+ data = json.load(f)
216
+ # Handle ProfileManager array format → convert to dict format
217
+ if isinstance(data.get('profiles'), list):
218
+ converted = {}
219
+ for p in data['profiles']:
220
+ n = p.get('name', '')
221
+ if n:
222
+ converted[n] = p
223
+ data['profiles'] = converted
224
+ if 'active' in data and 'active_profile' not in data:
225
+ data['active_profile'] = data.pop('active')
226
+ return data
227
+ except (json.JSONDecodeError, IOError):
228
+ pass
229
+ return {
230
+ 'profiles': {'default': {'name': 'default', 'description': 'Default memory profile'}},
231
+ 'active_profile': 'default',
232
+ }
233
+
234
+
235
+ def _save_profiles_json(config: dict) -> None:
236
+ """Save profiles.json config."""
237
+ MEMORY_DIR.mkdir(parents=True, exist_ok=True)
238
+ config_file = MEMORY_DIR / "profiles.json"
239
+ with open(config_file, 'w') as f:
240
+ json.dump(config, f, indent=2)
241
+
242
+
81
243
  # ============================================================================
82
244
  # Pydantic Models (shared across routes)
83
245
  # ============================================================================
@@ -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,20 +53,22 @@ 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():
62
+ profiles = []
63
+ for p in merged:
64
+ name = p.get('name', p.get('profile_id', ''))
82
65
  count = _get_memory_count(name)
83
66
  profiles.append({
84
67
  "name": name,
85
- "description": info.get('description', ''),
68
+ "description": p.get('description', ''),
86
69
  "memory_count": count,
87
- "created_at": info.get('created_at', ''),
88
- "last_used": info.get('last_used', ''),
70
+ "created_at": p.get('created_at', ''),
71
+ "last_used": p.get('last_used', ''),
89
72
  "is_active": name == active,
90
73
  })
91
74
 
@@ -101,24 +84,29 @@ async def list_profiles():
101
84
 
102
85
  @router.post("/api/profiles/{name}/switch")
103
86
  async def switch_profile(name: str):
104
- """Switch active memory profile."""
87
+ """Switch active memory profile (persists to both config stores)."""
105
88
  try:
106
89
  if not validate_profile_name(name):
107
90
  raise HTTPException(status_code=400, detail="Invalid profile name.")
108
91
 
109
- config = _load_profiles_config()
92
+ merged = sync_profiles()
93
+ merged_names = {p.get('name', p.get('profile_id', '')) for p in merged}
110
94
 
111
- if name not in config.get('profiles', {}):
112
- available = ', '.join(config.get('profiles', {}).keys())
95
+ if name not in merged_names:
96
+ available = ', '.join(sorted(merged_names))
113
97
  raise HTTPException(
114
98
  status_code=404,
115
99
  detail=f"Profile '{name}' not found. Available: {available}",
116
100
  )
117
101
 
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)
102
+ previous = _load_profiles_json().get('active_profile', 'default')
103
+ set_active_profile_everywhere(name)
104
+
105
+ # Update last_used in profiles.json
106
+ json_config = _load_profiles_json()
107
+ if name in json_config.get('profiles', {}):
108
+ json_config['profiles'][name]['last_used'] = datetime.now().isoformat()
109
+ _save_profiles_json(json_config)
122
110
 
123
111
  count = _get_memory_count(name)
124
112
 
@@ -143,22 +131,22 @@ async def switch_profile(name: str):
143
131
 
144
132
  @router.post("/api/profiles/create")
145
133
  async def create_profile(body: ProfileSwitch):
146
- """Create a new memory profile."""
134
+ """Create a new memory profile (writes to BOTH SQLite and profiles.json)."""
147
135
  try:
148
136
  name = body.profile_name
149
137
  if not validate_profile_name(name):
150
138
  raise HTTPException(status_code=400, detail="Invalid profile name")
151
139
 
152
- config = _load_profiles_config()
153
-
154
- if name in config.get('profiles', {}):
140
+ # Check both stores for duplicates
141
+ merged = sync_profiles()
142
+ merged_names = {p.get('name', p.get('profile_id', '')) for p in merged}
143
+ if name in merged_names:
155
144
  raise HTTPException(status_code=409, detail=f"Profile '{name}' already exists")
156
145
 
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)
146
+ # Write to BOTH stores atomically
147
+ desc = f'Memory profile: {name}'
148
+ ensure_profile_in_db(name, desc)
149
+ ensure_profile_in_json(name, desc)
162
150
 
163
151
  return {"success": True, "profile": name, "message": f"Profile '{name}' created"}
164
152
 
@@ -175,16 +163,18 @@ async def delete_profile(name: str):
175
163
  if name == 'default':
176
164
  raise HTTPException(status_code=400, detail="Cannot delete 'default' profile")
177
165
 
178
- config = _load_profiles_config()
179
-
180
- if name not in config.get('profiles', {}):
166
+ merged = sync_profiles()
167
+ merged_names = {p.get('name', p.get('profile_id', '')) for p in merged}
168
+ if name not in merged_names:
181
169
  raise HTTPException(status_code=404, detail=f"Profile '{name}' not found")
182
- if config.get('active_profile') == name:
170
+
171
+ json_config = _load_profiles_json()
172
+ if json_config.get('active_profile') == name:
183
173
  raise HTTPException(status_code=400, detail="Cannot delete active profile.")
184
174
 
175
+ # Move data to default before deleting (bypasses CASCADE)
185
176
  conn = get_db_connection()
186
177
  cursor = conn.cursor()
187
- # Move memories to default (try V3 first, then V2)
188
178
  moved = 0
189
179
  try:
190
180
  cursor.execute(
@@ -196,7 +186,7 @@ async def delete_profile(name: str):
196
186
  pass
197
187
  try:
198
188
  cursor.execute(
199
- "UPDATE memories SET profile = 'default' WHERE profile = ?",
189
+ "UPDATE memories SET profile_id = 'default' WHERE profile_id = ?",
200
190
  (name,),
201
191
  )
202
192
  moved += cursor.rowcount
@@ -205,8 +195,13 @@ async def delete_profile(name: str):
205
195
  conn.commit()
206
196
  conn.close()
207
197
 
208
- del config['profiles'][name]
209
- _save_profiles_config(config)
198
+ # Delete from BOTH stores
199
+ delete_profile_from_db(name)
200
+
201
+ profiles = json_config.get('profiles', {})
202
+ profiles.pop(name, None)
203
+ json_config['profiles'] = profiles
204
+ _save_profiles_json(json_config)
210
205
 
211
206
  return {
212
207
  "success": True,