superlocalmemory 3.8.14 → 4.0.1

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.
Files changed (211) hide show
  1. package/ATTRIBUTION.md +4 -4
  2. package/CHANGELOG.md +124 -137
  3. package/README.md +69 -66
  4. package/docs/pi-dev-integration.md +1 -1
  5. package/package.json +6 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/CLAUDE.md +3 -3
  8. package/plugin/agents/slm-governance-advisor.md +1 -1
  9. package/plugin/agents/slm-loop-runner.md +1 -1
  10. package/plugin/agents/slm-memory-advisor.md +1 -1
  11. package/plugin/agents/slm-optimize-advisor.md +1 -1
  12. package/plugin/requirements.txt +1 -1
  13. package/plugin/skills/slm-cache/SKILL.md +1 -1
  14. package/plugin/skills/slm-compress/SKILL.md +1 -1
  15. package/plugin/skills/slm-governance/SKILL.md +1 -1
  16. package/plugin/skills/slm-graph/SKILL.md +1 -1
  17. package/plugin/skills/slm-loop/SKILL.md +1 -1
  18. package/plugin/skills/slm-mesh/SKILL.md +1 -1
  19. package/plugin/skills/slm-profile/SKILL.md +1 -1
  20. package/plugin/skills/slm-recall/SKILL.md +1 -1
  21. package/plugin/skills/slm-remember/SKILL.md +1 -1
  22. package/plugin/skills/slm-scope/SKILL.md +1 -1
  23. package/plugin/skills/slm-session/SKILL.md +1 -1
  24. package/plugin/skills/slm-status/SKILL.md +1 -1
  25. package/plugin-src/rules/AGENTS.md +1 -1
  26. package/plugin-src/skills/slm-cache/SKILL.md +1 -1
  27. package/plugin-src/skills/slm-compress/SKILL.md +1 -1
  28. package/plugin-src/skills/slm-governance/SKILL.md +248 -0
  29. package/plugin-src/skills/slm-graph/SKILL.md +1 -1
  30. package/plugin-src/skills/slm-loop/SKILL.md +99 -0
  31. package/plugin-src/skills/slm-mesh/SKILL.md +282 -0
  32. package/plugin-src/skills/slm-profile/SKILL.md +148 -0
  33. package/plugin-src/skills/slm-recall/SKILL.md +1 -1
  34. package/plugin-src/skills/slm-remember/SKILL.md +1 -1
  35. package/plugin-src/skills/slm-scope/SKILL.md +176 -0
  36. package/plugin-src/skills/slm-session/SKILL.md +1 -1
  37. package/plugin-src/skills/slm-status/SKILL.md +1 -1
  38. package/pyproject.toml +11 -4
  39. package/src/superlocalmemory/__init__.py +1 -1
  40. package/src/superlocalmemory/cli/commands.py +125 -11
  41. package/src/superlocalmemory/cli/daemon.py +5 -1
  42. package/src/superlocalmemory/cli/main.py +35 -2
  43. package/src/superlocalmemory/cli/ops_cmd.py +281 -0
  44. package/src/superlocalmemory/cli/setup_wizard.py +1 -1
  45. package/src/superlocalmemory/compliance/audit.py +65 -0
  46. package/src/superlocalmemory/compliance/eu_ai_act.py +27 -57
  47. package/src/superlocalmemory/compliance/gdpr.py +416 -20
  48. package/src/superlocalmemory/compliance/retention.py +74 -22
  49. package/src/superlocalmemory/compliance/scheduler.py +78 -9
  50. package/src/superlocalmemory/core/actor_context.py +166 -0
  51. package/src/superlocalmemory/core/admission.py +549 -0
  52. package/src/superlocalmemory/core/backend_orchestrator.py +23 -10
  53. package/src/superlocalmemory/core/config.py +202 -24
  54. package/src/superlocalmemory/core/consolidation_engine.py +13 -13
  55. package/src/superlocalmemory/core/context_cache.py +28 -0
  56. package/src/superlocalmemory/core/embeddings.py +64 -2
  57. package/src/superlocalmemory/core/engine.py +7 -2
  58. package/src/superlocalmemory/core/engine_ingestion.py +65 -3
  59. package/src/superlocalmemory/core/engine_wiring.py +32 -5
  60. package/src/superlocalmemory/core/ingest_policy.py +38 -0
  61. package/src/superlocalmemory/core/maintenance.py +255 -0
  62. package/src/superlocalmemory/core/modes.py +40 -13
  63. package/src/superlocalmemory/core/mutations.py +437 -44
  64. package/src/superlocalmemory/core/operation_policy.py +92 -0
  65. package/src/superlocalmemory/core/operation_policy_registry.py +542 -0
  66. package/src/superlocalmemory/core/operation_request.py +127 -0
  67. package/src/superlocalmemory/core/ops_remediation.py +542 -0
  68. package/src/superlocalmemory/core/recall_pipeline.py +7 -0
  69. package/src/superlocalmemory/core/remember_runtime.py +202 -4
  70. package/src/superlocalmemory/core/remote_mode.py +20 -5
  71. package/src/superlocalmemory/core/store_pipeline.py +150 -0
  72. package/src/superlocalmemory/core/topic_signature.py +19 -4
  73. package/src/superlocalmemory/core/transactions/__init__.py +78 -0
  74. package/src/superlocalmemory/core/transactions/concrete_owners.py +597 -0
  75. package/src/superlocalmemory/core/transactions/erasure.py +825 -0
  76. package/src/superlocalmemory/core/transactions/manifest.py +255 -0
  77. package/src/superlocalmemory/core/transactions/manifest_key.py +155 -0
  78. package/src/superlocalmemory/core/transactions/obligations.py +272 -0
  79. package/src/superlocalmemory/core/transactions/owners.py +114 -0
  80. package/src/superlocalmemory/core/transactions/reconciler.py +285 -0
  81. package/src/superlocalmemory/core/transactions/service.py +330 -0
  82. package/src/superlocalmemory/core/worker_pool.py +33 -5
  83. package/src/superlocalmemory/encoding/cognitive_consolidator.py +70 -28
  84. package/src/superlocalmemory/encoding/emotional.py +75 -14
  85. package/src/superlocalmemory/encoding/temporal_parser.py +4 -0
  86. package/src/superlocalmemory/evolution/blind_verifier.py +11 -4
  87. package/src/superlocalmemory/evolution/evolution_store.py +244 -4
  88. package/src/superlocalmemory/evolution/llm_dispatch.py +40 -0
  89. package/src/superlocalmemory/evolution/model_selection.py +18 -3
  90. package/src/superlocalmemory/evolution/mutation_generator.py +3 -0
  91. package/src/superlocalmemory/evolution/skill_activator.py +270 -0
  92. package/src/superlocalmemory/evolution/skill_evolver.py +281 -59
  93. package/src/superlocalmemory/evolution/types.py +30 -8
  94. package/src/superlocalmemory/graph/cozo_backend.py +17 -9
  95. package/src/superlocalmemory/hooks/auto_invoker.py +2 -1
  96. package/src/superlocalmemory/hooks/auto_recall.py +64 -30
  97. package/src/superlocalmemory/hooks/codex_assets.py +14 -1
  98. package/src/superlocalmemory/infra/backup.py +434 -7
  99. package/src/superlocalmemory/infra/process_reaper.py +18 -0
  100. package/src/superlocalmemory/infra/self_heal.py +401 -0
  101. package/src/superlocalmemory/learning/feedback.py +52 -9
  102. package/src/superlocalmemory/loops/engine.py +10 -0
  103. package/src/superlocalmemory/mcp/_daemon_proxy.py +3 -0
  104. package/src/superlocalmemory/mcp/http_transport.py +30 -331
  105. package/src/superlocalmemory/mcp/profiles.py +5 -0
  106. package/src/superlocalmemory/mcp/resources.py +8 -0
  107. package/src/superlocalmemory/mcp/server.py +51 -4
  108. package/src/superlocalmemory/mcp/shared.py +19 -0
  109. package/src/superlocalmemory/mcp/tools_active.py +25 -4
  110. package/src/superlocalmemory/mcp/tools_code_graph.py +26 -18
  111. package/src/superlocalmemory/mcp/tools_context.py +50 -8
  112. package/src/superlocalmemory/mcp/tools_core.py +69 -21
  113. package/src/superlocalmemory/mcp/tools_evolution.py +9 -2
  114. package/src/superlocalmemory/mcp/tools_learning.py +21 -10
  115. package/src/superlocalmemory/mcp/tools_loops.py +29 -18
  116. package/src/superlocalmemory/mcp/tools_mesh.py +8 -0
  117. package/src/superlocalmemory/mcp/tools_ops.py +115 -0
  118. package/src/superlocalmemory/mcp/tools_optimize.py +4 -0
  119. package/src/superlocalmemory/mcp/tools_v28.py +10 -3
  120. package/src/superlocalmemory/mcp/tools_v3.py +34 -14
  121. package/src/superlocalmemory/mcp/tools_v33.py +18 -33
  122. package/src/superlocalmemory/mesh/broker.py +124 -46
  123. package/src/superlocalmemory/mesh/broker_security.py +470 -0
  124. package/src/superlocalmemory/mesh/discovery.py +365 -0
  125. package/src/superlocalmemory/mesh/lock_protocol.py +313 -0
  126. package/src/superlocalmemory/mesh/node_identity.py +97 -0
  127. package/src/superlocalmemory/mesh/outbox_remote.py +429 -0
  128. package/src/superlocalmemory/mesh/remote_sync.py +511 -28
  129. package/src/superlocalmemory/mesh/state_sync.py +286 -0
  130. package/src/superlocalmemory/optimize/config/store.py +45 -0
  131. package/src/superlocalmemory/parameterization/cross_project.py +12 -0
  132. package/src/superlocalmemory/parameterization/prompt_injector.py +13 -11
  133. package/src/superlocalmemory/parameterization/prompt_lifecycle.py +8 -2
  134. package/src/superlocalmemory/parameterization/workflow_miner.py +17 -0
  135. package/src/superlocalmemory/retrieval/ann_index.py +5 -0
  136. package/src/superlocalmemory/retrieval/bm25_channel.py +49 -2
  137. package/src/superlocalmemory/retrieval/engine.py +19 -4
  138. package/src/superlocalmemory/retrieval/fusion.py +4 -1
  139. package/src/superlocalmemory/retrieval/hopfield_channel.py +9 -3
  140. package/src/superlocalmemory/retrieval/remote_reranker.py +47 -22
  141. package/src/superlocalmemory/retrieval/reranker.py +32 -1
  142. package/src/superlocalmemory/retrieval/temporal_channel.py +16 -3
  143. package/src/superlocalmemory/retrieval/temporal_utils.py +107 -0
  144. package/src/superlocalmemory/retrieval/temporal_validity_filter.py +155 -42
  145. package/src/superlocalmemory/retrieval/vector_store.py +214 -8
  146. package/src/superlocalmemory/server/api.py +5 -5
  147. package/src/superlocalmemory/server/egress_policy.py +258 -0
  148. package/src/superlocalmemory/server/rbac_enforce.py +32 -0
  149. package/src/superlocalmemory/server/route_mutations.py +20 -0
  150. package/src/superlocalmemory/server/routes/compliance.py +153 -7
  151. package/src/superlocalmemory/server/routes/data_io.py +43 -2
  152. package/src/superlocalmemory/server/routes/events.py +15 -0
  153. package/src/superlocalmemory/server/routes/memories.py +56 -3
  154. package/src/superlocalmemory/server/routes/mesh.py +82 -1
  155. package/src/superlocalmemory/server/routes/mesh_lock.py +54 -0
  156. package/src/superlocalmemory/server/routes/mesh_state.py +63 -0
  157. package/src/superlocalmemory/server/routes/v3_api.py +50 -27
  158. package/src/superlocalmemory/server/routes/ws.py +86 -0
  159. package/src/superlocalmemory/server/ui.py +12 -7
  160. package/src/superlocalmemory/server/unified_daemon.py +951 -120
  161. package/src/superlocalmemory/storage/_migration_internals.py +568 -0
  162. package/src/superlocalmemory/storage/_schema_version.py +110 -0
  163. package/src/superlocalmemory/storage/database.py +329 -24
  164. package/src/superlocalmemory/storage/embedding_migrator.py +246 -51
  165. package/src/superlocalmemory/storage/erasure_fence.py +45 -0
  166. package/src/superlocalmemory/storage/generation_fence.py +63 -0
  167. package/src/superlocalmemory/storage/migration_runner.py +140 -424
  168. package/src/superlocalmemory/storage/migrations/M009_model_lineage.py +40 -0
  169. package/src/superlocalmemory/storage/migrations/M033_projection_transactions.py +148 -0
  170. package/src/superlocalmemory/storage/migrations/M034_obligation_integrity.py +58 -0
  171. package/src/superlocalmemory/storage/migrations/M035_erasure_receipts.py +113 -0
  172. package/src/superlocalmemory/storage/migrations/M036_vector_row_map.py +107 -0
  173. package/src/superlocalmemory/storage/migrations/M037_manifest_hmac_version.py +162 -0
  174. package/src/superlocalmemory/storage/migrations/{M033_learning_feedback_channel.py → M038_learning_feedback_channel.py} +3 -3
  175. package/src/superlocalmemory/storage/migrations/{M034_scene_fact_members.py → M039_scene_fact_members.py} +15 -5
  176. package/src/superlocalmemory/storage/migrations/__init__.py +4 -4
  177. package/src/superlocalmemory/storage/schema.py +10 -2
  178. package/src/superlocalmemory/storage/write_coordinator.py +125 -0
  179. package/src/superlocalmemory/trust/scorer.py +28 -4
  180. package/src/superlocalmemory/ui/index.html +16 -5
  181. package/src/superlocalmemory/ui/js/auto-settings.js +12 -1
  182. package/src/superlocalmemory/ui/js/brain.js +6 -4
  183. package/src/superlocalmemory/ui/js/compliance.js +66 -12
  184. package/src/superlocalmemory/ui/js/dashboard.js +13 -3
  185. package/src/superlocalmemory/ui/js/feedback.js +8 -2
  186. package/src/superlocalmemory/ui/js/lifecycle.js +7 -1
  187. package/src/superlocalmemory/ui/js/modal.js +272 -5
  188. package/src/superlocalmemory/ui/js/od-backup.js +9 -2
  189. package/src/superlocalmemory/ui/js/od-compliance-ext.js +301 -0
  190. package/src/superlocalmemory/ui/js/od-operations.js +154 -23
  191. package/src/superlocalmemory/ui/js/od-ops-health.js +417 -0
  192. package/src/superlocalmemory/ui/js/od-optimize.js +35 -21
  193. package/src/superlocalmemory/ui/js/od-team.js +9 -2
  194. package/src/superlocalmemory/ui/js/optimize.js +13 -16
  195. package/src/superlocalmemory/ui/js/profiles.js +7 -3
  196. package/src/superlocalmemory/ui/js/settings.js +7 -1
  197. package/src/superlocalmemory/vector/lancedb_backend.py +19 -9
  198. package/src/superlocalmemory/attribution/mathematical_dna.py +0 -235
  199. package/src/superlocalmemory/cli/post_install.py +0 -114
  200. package/src/superlocalmemory/core/clock_monitor.py +0 -45
  201. package/src/superlocalmemory/core/db_pool.py +0 -80
  202. package/src/superlocalmemory/core/error_catalog.py +0 -113
  203. package/src/superlocalmemory/core/loop_watchdog.py +0 -56
  204. package/src/superlocalmemory/core/priority_queue.py +0 -61
  205. package/src/superlocalmemory/core/pruning_engine.py +0 -216
  206. package/src/superlocalmemory/core/queue_dispatcher.py +0 -73
  207. package/src/superlocalmemory/core/slmignore.py +0 -125
  208. package/src/superlocalmemory/infra/heartbeat_monitor.py +0 -140
  209. package/src/superlocalmemory/infra/webhook_dispatcher.py +0 -247
  210. package/src/superlocalmemory/learning/quantization_scheduler.py +0 -320
  211. package/src/superlocalmemory/storage/access_control.py +0 -182
@@ -12,9 +12,11 @@ Part of Qualixar | Author: Varun Pratap Bhardwaj
12
12
 
13
13
  from __future__ import annotations
14
14
 
15
+ import hashlib
15
16
  import json
16
17
  import logging
17
18
  import sqlite3
19
+ import warnings
18
20
  from datetime import datetime, timezone
19
21
  from pathlib import Path
20
22
  from typing import Optional
@@ -61,6 +63,44 @@ CREATE TABLE IF NOT EXISTS evolution_cycle_state (
61
63
  updated_at TEXT,
62
64
  PRIMARY KEY (profile_id, key)
63
65
  );
66
+
67
+ -- Phase 2: append-only status-transition log (LLD Decision B2).
68
+ -- Each state change for a record produces one new row here.
69
+ -- The BEFORE UPDATE trigger enforces immutability at the DB layer.
70
+ CREATE TABLE IF NOT EXISTS skill_evolution_transitions (
71
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
72
+ record_id TEXT NOT NULL,
73
+ profile_id TEXT NOT NULL DEFAULT 'default',
74
+ from_status TEXT NOT NULL,
75
+ to_status TEXT NOT NULL,
76
+ transitioned_at TEXT NOT NULL,
77
+ actor_id TEXT DEFAULT '',
78
+ reason TEXT DEFAULT '',
79
+ prev_hash TEXT DEFAULT '',
80
+ transition_hash TEXT NOT NULL,
81
+ metadata TEXT DEFAULT '{}'
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_evo_trans_record
85
+ ON skill_evolution_transitions(record_id, seq);
86
+
87
+ CREATE INDEX IF NOT EXISTS idx_evo_trans_profile
88
+ ON skill_evolution_transitions(profile_id, transitioned_at);
89
+
90
+ -- DB-enforced append-only: any UPDATE on this table is a bug (LLD Decision B2).
91
+ CREATE TRIGGER IF NOT EXISTS no_update_evo_transitions
92
+ BEFORE UPDATE ON skill_evolution_transitions
93
+ BEGIN
94
+ SELECT RAISE(ABORT, 'skill_evolution_transitions is append-only');
95
+ END;
96
+
97
+ -- Audit P1-3: DELETE must also be forbidden — otherwise the hash chain can be
98
+ -- silently truncated/erased without a DB abort, breaking the immutable-log claim.
99
+ CREATE TRIGGER IF NOT EXISTS no_delete_evo_transitions
100
+ BEFORE DELETE ON skill_evolution_transitions
101
+ BEGIN
102
+ SELECT RAISE(ABORT, 'skill_evolution_transitions is append-only');
103
+ END;
64
104
  """
65
105
 
66
106
  # Anti-loop budget
@@ -223,11 +263,194 @@ class EvolutionStore:
223
263
  for k in recovered:
224
264
  del self._addressed_degradations[k]
225
265
 
266
+ # ------------------------------------------------------------------
267
+ # Phase 2: append-only transition log
268
+ # ------------------------------------------------------------------
269
+
270
+ def insert_record(self, record: EvolutionRecord, profile_id: str) -> None:
271
+ """INSERT a new evolution record (CANDIDATE status).
272
+
273
+ Unlike save_record, this uses plain INSERT — NOT INSERT OR REPLACE.
274
+ Raises sqlite3.IntegrityError if a row with the same id already exists.
275
+ Call this exactly once per candidate (CRIT-1: never reuse record_id).
276
+ """
277
+ conn = sqlite3.connect(self._db_path, timeout=10)
278
+ try:
279
+ conn.execute(
280
+ "INSERT INTO skill_evolution_log "
281
+ "(id, profile_id, skill_name, parent_skill_id, evolution_type, "
282
+ " trigger_type, generation, status, mutation_summary, evidence, "
283
+ " original_content, evolved_content, content_diff, "
284
+ " blind_verified, rejection_reason, created_at, completed_at) "
285
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
286
+ (
287
+ record.id,
288
+ profile_id,
289
+ record.skill_name,
290
+ record.parent_skill_id,
291
+ record.evolution_type.value,
292
+ record.trigger.value,
293
+ record.generation,
294
+ record.status.value,
295
+ record.mutation_summary,
296
+ json.dumps(list(record.evidence)),
297
+ record.original_content,
298
+ record.evolved_content,
299
+ record.content_diff,
300
+ 1 if record.blind_verified else 0,
301
+ record.rejection_reason,
302
+ record.created_at,
303
+ record.completed_at,
304
+ ),
305
+ )
306
+ conn.commit()
307
+ finally:
308
+ conn.close()
309
+
310
+ def append_transition(
311
+ self,
312
+ record_id: str,
313
+ profile_id: str,
314
+ from_status: EvolutionStatus,
315
+ to_status: EvolutionStatus,
316
+ *,
317
+ actor_id: str = "",
318
+ reason: str = "",
319
+ metadata: dict | None = None,
320
+ ) -> str:
321
+ """Append an immutable status-transition row. Returns transition_hash.
322
+
323
+ Hash linkage (audit P2-3): transition_hash = SHA-256 over a canonical,
324
+ pipe-delimited payload covering EVERY persisted field —
325
+ ``prev_hash | record_id | from_status | to_status | ts | actor_id |
326
+ reason | metadata_str``. Delimiters remove concatenation ambiguity and
327
+ including reason+metadata makes those columns tamper-evident too.
328
+
329
+ prev_hash is the transition_hash of the most recent row for this
330
+ record_id, or 'genesis' for the first transition.
331
+
332
+ Concurrency (audit P1-5): the read-prev-then-insert is wrapped in a
333
+ single ``BEGIN IMMEDIATE`` transaction so two concurrent writers cannot
334
+ both read the same prev_hash and fork the chain.
335
+
336
+ Never calls UPDATE or DELETE. Raises ValueError if from_status == to_status.
337
+ """
338
+ if from_status == to_status:
339
+ raise ValueError(
340
+ f"append_transition: from_status == to_status == {from_status!r}; "
341
+ "no-op transitions are not allowed in the append-only log."
342
+ )
343
+ ts = datetime.now(timezone.utc).isoformat()
344
+ metadata_str = json.dumps(metadata or {}, sort_keys=True)
345
+
346
+ # isolation_level=None + explicit BEGIN IMMEDIATE = one atomic
347
+ # select-prev-then-insert critical section per call (P1-5).
348
+ conn = sqlite3.connect(self._db_path, timeout=10, isolation_level=None)
349
+ try:
350
+ conn.execute("BEGIN IMMEDIATE")
351
+ # Find prev_hash for this record_id (genesis if first)
352
+ row = conn.execute(
353
+ "SELECT transition_hash FROM skill_evolution_transitions "
354
+ "WHERE record_id = ? AND profile_id = ? "
355
+ "ORDER BY seq DESC LIMIT 1",
356
+ (record_id, profile_id),
357
+ ).fetchone()
358
+ prev_hash = row[0] if row else "genesis"
359
+
360
+ # Canonical, delimited payload over ALL persisted fields (P2-3).
361
+ payload = "|".join((
362
+ prev_hash, record_id,
363
+ from_status.value, to_status.value,
364
+ ts, actor_id, reason, metadata_str,
365
+ ))
366
+ transition_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest()
367
+
368
+ conn.execute(
369
+ "INSERT INTO skill_evolution_transitions "
370
+ "(record_id, profile_id, from_status, to_status, "
371
+ " transitioned_at, actor_id, reason, prev_hash, "
372
+ " transition_hash, metadata) "
373
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
374
+ (
375
+ record_id,
376
+ profile_id,
377
+ from_status.value,
378
+ to_status.value,
379
+ ts,
380
+ actor_id,
381
+ reason,
382
+ prev_hash,
383
+ transition_hash,
384
+ metadata_str,
385
+ ),
386
+ )
387
+ conn.execute("COMMIT")
388
+ return transition_hash
389
+ except Exception:
390
+ try:
391
+ conn.execute("ROLLBACK")
392
+ except Exception:
393
+ pass
394
+ raise
395
+ finally:
396
+ conn.close()
397
+
398
+ def get_latest_status(
399
+ self, record_id: str, profile_id: str,
400
+ ) -> EvolutionStatus | None:
401
+ """Return the to_status of the highest-seq transition for record_id.
402
+
403
+ Returns None if no transitions exist for this record_id / profile_id.
404
+ """
405
+ conn = sqlite3.connect(self._db_path, timeout=10)
406
+ try:
407
+ row = conn.execute(
408
+ "SELECT to_status FROM skill_evolution_transitions "
409
+ "WHERE record_id = ? AND profile_id = ? "
410
+ "ORDER BY seq DESC LIMIT 1",
411
+ (record_id, profile_id),
412
+ ).fetchone()
413
+ if row is None:
414
+ return None
415
+ return EvolutionStatus(row[0])
416
+ finally:
417
+ conn.close()
418
+
419
+ def get_transitions(self, record_id: str, profile_id: str) -> list[dict]:
420
+ """Return all transition rows for record_id ordered by seq ASC."""
421
+ conn = sqlite3.connect(self._db_path, timeout=10)
422
+ conn.row_factory = sqlite3.Row
423
+ try:
424
+ rows = conn.execute(
425
+ "SELECT seq, record_id, profile_id, from_status, to_status, "
426
+ "transitioned_at, actor_id, reason, prev_hash, transition_hash, "
427
+ "metadata "
428
+ "FROM skill_evolution_transitions "
429
+ "WHERE record_id = ? AND profile_id = ? "
430
+ "ORDER BY seq ASC",
431
+ (record_id, profile_id),
432
+ ).fetchall()
433
+ return [dict(r) for r in rows]
434
+ finally:
435
+ conn.close()
436
+
226
437
  # ------------------------------------------------------------------
227
438
  # CRUD
228
439
  # ------------------------------------------------------------------
229
440
 
230
441
  def save_record(self, record: EvolutionRecord, profile_id: str) -> None:
442
+ """DEPRECATED: use insert_record() for new records and append_transition() for state changes.
443
+
444
+ Retained for backward-compatibility with existing tests and callers.
445
+ Uses INSERT OR REPLACE — mutable semantics, not append-only.
446
+ Will be removed in a future cleanup pass (not Phase 2 scope).
447
+ """
448
+ warnings.warn(
449
+ "EvolutionStore.save_record() is deprecated; "
450
+ "use insert_record() + append_transition() instead.",
451
+ DeprecationWarning,
452
+ stacklevel=2,
453
+ )
231
454
  conn = sqlite3.connect(self._db_path, timeout=10)
232
455
  try:
233
456
  conn.execute(
@@ -306,13 +529,30 @@ class EvolutionStore:
306
529
  conn.close()
307
530
 
308
531
  def count_attempts(self, skill_name: str, profile_id: str) -> int:
532
+ """Count non-successful evolution attempts for a skill.
533
+
534
+ Audit P0-2: with the Phase-2 append-only model, ``skill_evolution_log``
535
+ rows are frozen at 'candidate' by ``insert_record`` and never updated, so
536
+ the legacy ``status NOT IN ('promoted')`` filter counted successes too —
537
+ permanently disabling a skill after MAX_ATTEMPTS_PER_SKILL improvements.
538
+ The current status is now read from the append-only transitions log
539
+ (latest ``to_status`` per record), and success states are excluded,
540
+ mirroring the pre-Phase-2 exclusion of 'promoted'. Legacy rows with no
541
+ transitions fall back to their frozen log status via COALESCE.
542
+ """
543
+ success = ("promoted", "verified_quarantined", "approved", "active")
544
+ marks = ",".join("?" for _ in success)
309
545
  conn = sqlite3.connect(self._db_path, timeout=10)
310
546
  try:
311
547
  row = conn.execute(
312
- "SELECT COUNT(*) FROM skill_evolution_log "
313
- "WHERE skill_name = ? AND profile_id = ? "
314
- "AND status NOT IN ('promoted')",
315
- (skill_name, profile_id),
548
+ "SELECT COUNT(*) FROM skill_evolution_log l "
549
+ "WHERE l.skill_name = ? AND l.profile_id = ? "
550
+ "AND COALESCE("
551
+ " (SELECT t.to_status FROM skill_evolution_transitions t "
552
+ " WHERE t.record_id = l.id AND t.profile_id = l.profile_id "
553
+ " ORDER BY t.seq DESC LIMIT 1), l.status"
554
+ f") NOT IN ({marks})",
555
+ (skill_name, profile_id, *success),
316
556
  ).fetchone()
317
557
  return row[0] if row else 0
318
558
  finally:
@@ -158,6 +158,7 @@ ALLOWED_LLM_MODELS: frozenset[str] = frozenset({
158
158
  "claude-sonnet-4-6",
159
159
  "ollama:llama3",
160
160
  "ollama:qwen2.5",
161
+ "openai:gpt-4o-mini",
161
162
  })
162
163
 
163
164
  FORBIDDEN_MODEL_SUBSTRINGS: tuple[str, ...] = ("opus", "gpt-4-turbo")
@@ -301,6 +302,43 @@ def _call_claude_api_backend(
301
302
  return ""
302
303
 
303
304
 
305
+ def _call_openai_api_backend(
306
+ prompt: str, *, model: str, max_tokens: int,
307
+ ) -> str:
308
+ """Call the OpenAI Chat Completions API directly.
309
+
310
+ The API model id is derived from the allow-listed name by stripping
311
+ the ``"openai:"`` prefix — e.g. ``"openai:gpt-4o-mini"`` → ``"gpt-4o-mini"``.
312
+ Requires ``OPENAI_API_KEY`` in the environment. Returns empty string on
313
+ any transport or SDK failure (fail-closed).
314
+ """
315
+ openai_model = model.split(":", 1)[1] if model.startswith("openai:") else model
316
+ try:
317
+ import openai as _openai # type: ignore[import-not-found]
318
+ except Exception as exc: # noqa: BLE001
319
+ logger.debug("openai sdk unavailable: %s", exc)
320
+ return ""
321
+
322
+ try:
323
+ client = _openai.OpenAI()
324
+ completion = client.chat.completions.create(
325
+ model=openai_model,
326
+ max_tokens=max_tokens,
327
+ messages=[{"role": "user", "content": prompt}],
328
+ )
329
+ choices = getattr(completion, "choices", None)
330
+ if choices and len(choices) > 0:
331
+ msg = getattr(choices[0], "message", None)
332
+ if msg:
333
+ content = getattr(msg, "content", None)
334
+ if isinstance(content, str):
335
+ return content
336
+ return ""
337
+ except Exception as exc: # noqa: BLE001
338
+ logger.debug("OpenAI API backend failed: %s", exc)
339
+ return ""
340
+
341
+
304
342
  # ---------------------------------------------------------------------------
305
343
  # Backend registry — dispatches by (allow-listed) model id
306
344
  # ---------------------------------------------------------------------------
@@ -337,6 +375,8 @@ def _pick_backend(model: str) -> Callable[..., str]:
337
375
  """
338
376
  if model.startswith("ollama:"):
339
377
  return _call_ollama_backend
378
+ if model.startswith("openai:"):
379
+ return _call_openai_api_backend
340
380
  if model.startswith("claude-"):
341
381
  # Claude CLI path is an alternative — selected when an explicit
342
382
  # env flag is set. Default path is the Anthropic API backend.
@@ -39,6 +39,7 @@ CHEAPEST_CLAUDE = "claude-haiku-4-5"
39
39
  QUALITY_CLAUDE = "claude-sonnet-4-6"
40
40
  CHEAPEST_OLLAMA = "ollama:llama3"
41
41
  ALT_OLLAMA = "ollama:qwen2.5"
42
+ CHEAPEST_OPENAI = "openai:gpt-4o-mini"
42
43
 
43
44
  # Short-name → allow-listed model id. Single source of truth for aliasing;
44
45
  # ``skill_evolver`` re-exports these for backward compatibility.
@@ -48,6 +49,8 @@ _MODEL_ALIASES: dict[str, str] = {
48
49
  "ollama": CHEAPEST_OLLAMA,
49
50
  "ollama:llama3": CHEAPEST_OLLAMA,
50
51
  "ollama:qwen2.5": ALT_OLLAMA,
52
+ "openai": CHEAPEST_OPENAI,
53
+ "openai:gpt-4o-mini": CHEAPEST_OPENAI,
51
54
  CHEAPEST_CLAUDE: CHEAPEST_CLAUDE,
52
55
  QUALITY_CLAUDE: QUALITY_CLAUDE,
53
56
  }
@@ -78,11 +81,18 @@ class ResolvedModels:
78
81
 
79
82
 
80
83
  def _cheapest_for_backend(backend: str) -> str:
81
- """Lowest-cost allow-listed model for a detected backend."""
84
+ """Lowest-cost allow-listed model for a detected backend.
85
+
86
+ The configured backend is authoritative: when the caller explicitly
87
+ names a provider, models are drawn from that provider only —
88
+ never silently downgraded to a different vendor's offering.
89
+ """
82
90
  if backend == "ollama":
83
91
  return CHEAPEST_OLLAMA
84
- # claude CLI, anthropic API, and any non-Ollama backend evolution can
85
- # actually dispatch route through the cheapest Claude model.
92
+ if backend == "openai":
93
+ return CHEAPEST_OPENAI
94
+ # claude CLI, anthropic API, and any non-Ollama/non-OpenAI backend
95
+ # route through the cheapest Claude model.
86
96
  return CHEAPEST_CLAUDE
87
97
 
88
98
 
@@ -106,6 +116,8 @@ def _independent_verifier(
106
116
  """Pick a cheap verifier that differs from the generator when possible.
107
117
 
108
118
  - Ollama generator → the *other* local model (both free, distinct).
119
+ - OpenAI generator → same OpenAI model (single-provider constraint;
120
+ independence flag will be False — caller logs the condition).
109
121
  - Claude generator + local Ollama up → free local verifier.
110
122
  - Claude generator, no Ollama → the cheapest *different* Claude tier if
111
123
  the generator was a premium model; otherwise reuse the cheapest model
@@ -114,6 +126,9 @@ def _independent_verifier(
114
126
  """
115
127
  if mutation.startswith("ollama:"):
116
128
  return ALT_OLLAMA if mutation != ALT_OLLAMA else CHEAPEST_OLLAMA
129
+ if mutation.startswith("openai:"):
130
+ # Honor the configured provider: stay within OpenAI models.
131
+ return CHEAPEST_OPENAI
117
132
  if ollama_available:
118
133
  return CHEAPEST_OLLAMA
119
134
  if mutation != CHEAPEST_CLAUDE:
@@ -97,6 +97,9 @@ _SKILL_DENY_PATTERNS: tuple[str, ...] = (
97
97
  "os.environ", "subprocess", "exec(", "eval(", "__import__", "import os",
98
98
  "import subprocess", "pickle.loads", "curl ", "wget ", "rm -rf",
99
99
  "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "AWS_SECRET", "/.ssh/", ".install_token",
100
+ # Semantic exfiltration patterns — skills must not instruct unauthorized
101
+ # data transfer or bypass of user consent.
102
+ "without consent", "exfiltrat",
100
103
  )
101
104
 
102
105
 
@@ -0,0 +1,270 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under AGPL-3.0-or-later — see LICENSE file
3
+ # Part of SuperLocalMemory V4 | https://qualixar.com | https://varunpratap.com
4
+
5
+ """SkillActivator — atomic activation of quarantined evolved skills.
6
+
7
+ Copies a VERIFIED_QUARANTINED skill artifact into the live skill directory
8
+ (~/.claude/skills/{skill_name}/SKILL.md), retains the prior artifact as a
9
+ .bak file, and provides a tested rollback path.
10
+
11
+ Invariants:
12
+ 1. The destination directory is a child of live_root — path traversal raises ValueError.
13
+ 2. The backup is written BEFORE the live copy is overwritten.
14
+ 3. Activation is atomic via a .tmp file → os.replace (POSIX atomic on same fs).
15
+ 4. Rollback restores from the .bak file and leaves the live copy unchanged.
16
+ 5. Only the quarantined artifact identified by quarantine_dir_name is copied;
17
+ no arbitrary file can be activated.
18
+
19
+ CRIT-2 note: skill_name and quarantine_dir_name are ALWAYS different strings:
20
+ - skill_name: the original skill identifier (e.g. "brainstorming")
21
+ - quarantine_dir_name: the sanitized quarantine subdir (e.g. "brainstorming-vabc12")
22
+ Both are required; neither defaults from the other.
23
+
24
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import hashlib
30
+ import logging
31
+ import os
32
+ import re
33
+ import shutil
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+
37
+ from superlocalmemory.infra.data_root import canonical_data_root
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ LIVE_SKILLS_ROOT: Path = Path.home() / ".claude" / "skills"
42
+ BACKUP_ROOT: Path = canonical_data_root() / "skill_backups"
43
+ QUARANTINE_ROOT: Path = canonical_data_root() / "quarantine" / "skills"
44
+
45
+ _SAFE_NAME_RE = re.compile(r"[^a-zA-Z0-9_-]")
46
+
47
+
48
+ class SkillActivationError(Exception):
49
+ """Raised when activation fails after writing the backup.
50
+
51
+ The caller must invoke rollback() after receiving this error to restore
52
+ the previous live artifact.
53
+ """
54
+
55
+
56
+ class SkillActivator:
57
+ """Activates a quarantined evolved skill into the live skill directory."""
58
+
59
+ def __init__(
60
+ self,
61
+ *,
62
+ live_root: Path | None = None,
63
+ backup_root: Path | None = None,
64
+ quarantine_root: Path | None = None,
65
+ ) -> None:
66
+ self._live_root = live_root or LIVE_SKILLS_ROOT
67
+ self._backup_root = backup_root or BACKUP_ROOT
68
+ self._quarantine_root = quarantine_root or QUARANTINE_ROOT
69
+
70
+ # ------------------------------------------------------------------
71
+ # Path helpers
72
+ # ------------------------------------------------------------------
73
+
74
+ def _safe_skill_dir(self, skill_name: str, base: Path) -> Path:
75
+ """Return base / sanitized_name, raising ValueError on traversal.
76
+
77
+ Two-layer check (fail-fast + belt-and-suspenders):
78
+ 1. Reject the raw skill_name if it contains '..' or is absolute.
79
+ 2. After sanitization, resolve the target path and verify it remains
80
+ inside base. This catches any edge case the regex misses.
81
+ """
82
+ # Fail-fast: detect traversal in raw name before sanitization.
83
+ raw = Path(skill_name)
84
+ if raw.is_absolute() or ".." in raw.parts:
85
+ raise ValueError(
86
+ f"skill_name {skill_name!r} contains path traversal sequences"
87
+ )
88
+ safe = _SAFE_NAME_RE.sub("-", skill_name).lower()[:50] or "skill"
89
+ target = (base / safe).resolve()
90
+ base_resolved = base.resolve()
91
+ # Audit P2-5: is_relative_to is not prefix-fragile (a sibling like
92
+ # ".../skills-evil" cannot masquerade as being under ".../skills").
93
+ if not target.is_relative_to(base_resolved):
94
+ raise ValueError(
95
+ f"skill_name {skill_name!r} escapes sandbox: {target}"
96
+ )
97
+ return target
98
+
99
+ def _quarantine_path(self, quarantine_dir_name: str) -> Path:
100
+ """Return the SKILL.md path inside quarantine for a given dir name.
101
+
102
+ quarantine_dir_name is the sanitized directory name (CRIT-2), e.g.
103
+ 'brainstorming-vabc12'. It is NOT the skill_name.
104
+
105
+ Hardened (audit P0-1 / P1-4): reject absolute paths and '..' traversal,
106
+ then resolve() and require containment inside the quarantine root. The
107
+ resolve() step also defeats a symlinked quarantine dir that would
108
+ otherwise redirect the read outside the sandbox and inject arbitrary
109
+ content into a live skill.
110
+ """
111
+ raw = Path(quarantine_dir_name)
112
+ if raw.is_absolute() or ".." in raw.parts:
113
+ raise ValueError(
114
+ f"quarantine_dir_name {quarantine_dir_name!r} contains path traversal"
115
+ )
116
+ root = self._quarantine_root.resolve()
117
+ q_dir = (self._quarantine_root / quarantine_dir_name).resolve()
118
+ if not q_dir.is_relative_to(root):
119
+ raise ValueError(
120
+ f"quarantine_dir_name {quarantine_dir_name!r} escapes sandbox: {q_dir}"
121
+ )
122
+ q_path = (q_dir / "SKILL.md").resolve()
123
+ if not q_path.is_relative_to(root):
124
+ raise ValueError(
125
+ f"quarantine artifact escapes sandbox: {q_path}"
126
+ )
127
+ return q_path
128
+
129
+ def _live_path(self, skill_name: str) -> Path:
130
+ return self._safe_skill_dir(skill_name, self._live_root) / "SKILL.md"
131
+
132
+ def _backup_path(self, skill_name: str) -> Path:
133
+ return self._safe_skill_dir(skill_name, self._backup_root) / "SKILL.md.bak"
134
+
135
+ # ------------------------------------------------------------------
136
+ # Public API
137
+ # ------------------------------------------------------------------
138
+
139
+ def activate(
140
+ self,
141
+ skill_name: str,
142
+ quarantine_dir_name: str,
143
+ *,
144
+ actor_id: str = "",
145
+ ) -> dict:
146
+ """Atomically activate a quarantined skill.
147
+
148
+ Steps:
149
+ 1. Resolve quarantine_path using quarantine_dir_name and verify it exists.
150
+ 2. Read quarantine content and compute SHA-256.
151
+ 3. Write backup of existing live SKILL.md (if any) to backup_root.
152
+ 4. Write quarantine content to live path via .tmp → os.replace.
153
+ 5. Return activation metadata.
154
+
155
+ Args:
156
+ skill_name: The original skill identifier (CRIT-2), e.g. "brainstorming".
157
+ quarantine_dir_name: The sanitized quarantine subdir, e.g. "brainstorming-vabc12".
158
+ actor_id: Who triggered activation (e.g. "auto" or a user id).
159
+
160
+ Raises:
161
+ FileNotFoundError: Quarantine artifact does not exist.
162
+ ValueError: Path traversal detected in skill_name.
163
+ SkillActivationError: Any OS error after the backup was already written.
164
+ """
165
+ q_path = self._quarantine_path(quarantine_dir_name)
166
+ live_path = self._live_path(skill_name) # raises ValueError on traversal
167
+ backup_path = self._backup_path(skill_name)
168
+
169
+ if not q_path.exists():
170
+ raise FileNotFoundError(
171
+ f"Quarantine artifact not found: {q_path}"
172
+ )
173
+
174
+ content = q_path.read_bytes()
175
+ content_hash = hashlib.sha256(content).hexdigest()
176
+ ts = datetime.now(timezone.utc).isoformat()
177
+
178
+ # Step 3: backup existing live artifact BEFORE any overwrite
179
+ backup_path.parent.mkdir(parents=True, exist_ok=True)
180
+ backup_written = False
181
+ if live_path.exists():
182
+ shutil.copy2(live_path, backup_path)
183
+ backup_written = True
184
+ logger.info(
185
+ "skill_activator: backed up %s → %s",
186
+ live_path, backup_path,
187
+ )
188
+
189
+ # Step 4: atomic write via .tmp → rename (same directory = same fs)
190
+ live_path.parent.mkdir(parents=True, exist_ok=True)
191
+ tmp_path = live_path.with_suffix(".tmp")
192
+ try:
193
+ tmp_path.write_bytes(content)
194
+ os.replace(tmp_path, live_path)
195
+ except OSError as exc:
196
+ try:
197
+ tmp_path.unlink(missing_ok=True)
198
+ except OSError:
199
+ pass
200
+ raise SkillActivationError(
201
+ f"atomic write failed for {live_path}: {exc}"
202
+ ) from exc
203
+
204
+ logger.info(
205
+ "skill_activator: activated %s @ %s (sha256=%s, actor=%s)",
206
+ skill_name, live_path, content_hash[:12], actor_id,
207
+ )
208
+ return {
209
+ "skill_name": skill_name,
210
+ "live_path": str(live_path),
211
+ "backup_path": str(backup_path) if backup_written else None,
212
+ "content_hash": content_hash,
213
+ "activated_at": ts,
214
+ "actor_id": actor_id,
215
+ }
216
+
217
+ def rollback(self, skill_name: str) -> dict:
218
+ """Restore the live SKILL.md from the backup written by activate().
219
+
220
+ If no backup exists, removes the live file (the skill was new).
221
+ Never raises on missing backup — logs a warning instead.
222
+
223
+ Args:
224
+ skill_name: The original skill identifier, same value used in activate().
225
+ """
226
+ live_path = self._live_path(skill_name)
227
+ backup_path = self._backup_path(skill_name)
228
+ ts = datetime.now(timezone.utc).isoformat()
229
+
230
+ if backup_path.exists():
231
+ # Audit P2-4: restore atomically (tmp → os.replace) so a crash
232
+ # mid-rollback cannot leave a torn live SKILL.md.
233
+ live_path.parent.mkdir(parents=True, exist_ok=True)
234
+ tmp_path = live_path.with_suffix(".tmp")
235
+ tmp_path.write_bytes(backup_path.read_bytes())
236
+ os.replace(tmp_path, live_path)
237
+ logger.info(
238
+ "skill_activator: rolled back %s ← %s",
239
+ live_path, backup_path,
240
+ )
241
+ return {
242
+ "skill_name": skill_name,
243
+ "rolled_back": True,
244
+ "restored_from": str(backup_path),
245
+ "rolled_back_at": ts,
246
+ }
247
+ elif live_path.exists():
248
+ live_path.unlink()
249
+ logger.warning(
250
+ "skill_activator: no backup for %s; removed live file",
251
+ skill_name,
252
+ )
253
+ return {
254
+ "skill_name": skill_name,
255
+ "rolled_back": True,
256
+ "restored_from": None,
257
+ "note": "no backup; live file removed",
258
+ "rolled_back_at": ts,
259
+ }
260
+ else:
261
+ logger.warning(
262
+ "skill_activator: rollback called but no live file for %s",
263
+ skill_name,
264
+ )
265
+ return {
266
+ "skill_name": skill_name,
267
+ "rolled_back": False,
268
+ "note": "nothing to rollback",
269
+ "rolled_back_at": ts,
270
+ }